Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions games/api/__tests__/test_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from django.test import TestCase

from ...models import Game
from ..filters import GameFilterSet
from .factories import GameFactory


class GameFilterSetTestCase(TestCase):
def setUp(self):
# Create test games including games with spaces in names
self.game1 = GameFactory(
name="The Witcher 3",
platforms=False, # Don't create default platform to avoid manager filtering
)
self.game2 = GameFactory(
name="Cyberpunk 2077",
platforms=False, # Don't create default platform to avoid manager filtering
)
self.game3 = GameFactory(
name="Portal 2",
platforms=False, # Don't create default platform to avoid manager filtering
)
self.game4 = GameFactory(
name="Grand Theft Auto V",
platforms=False, # Don't create default platform to avoid manager filtering
)
self.game5 = GameFactory(
name="Red Dead Redemption 2",
platforms=False, # Don't create default platform to avoid manager filtering
)

def test_search_single_game(self):
"""Test filtering for a single game using the search filter"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "witcher"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 1)
self.assertEqual(filtered_games[0].name, "The Witcher 3")

def test_search_multiple_games(self):
"""Test filtering for multiple games using comma-separated names"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "witcher,cyberpunk"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 2)

game_names = [game.name for game in filtered_games]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Cyberpunk 2077", game_names)

def test_search_case_insensitive(self):
"""Test that the search filter is case insensitive"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "PORTAL"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 1)
self.assertEqual(filtered_games[0].name, "Portal 2")

def test_search_partial_match(self):
"""Test that the search filter supports partial matching"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "cyber"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 1)
self.assertEqual(filtered_games[0].name, "Cyberpunk 2077")

def test_search_with_whitespace(self):
"""Test that the search filter handles whitespace correctly"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": " witcher , portal "}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 2)

game_names = [game.name for game in filtered_games]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Portal 2", game_names)

def test_search_no_matches(self):
"""Test that the search filter returns empty queryset when no games match"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "nonexistent"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 0)

def test_search_empty_value(self):
"""Test that empty search filter returns all games"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": ""}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 5)

def test_search_with_space_in_names(self):
"""Test filtering for games with spaces in their names"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "grand theft,red dead"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 2)

game_names = [game.name for game in filtered_games]
self.assertIn("Grand Theft Auto V", game_names)
self.assertIn("Red Dead Redemption 2", game_names)

def test_search_full_name_with_spaces(self):
"""Test filtering using full game names that contain spaces"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "The Witcher 3,Grand Theft Auto"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 2)

game_names = [game.name for game in filtered_games]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Grand Theft Auto V", game_names)

def test_search_mixed_space_and_no_space_names(self):
"""Test filtering with a mix of games with and without spaces"""
queryset = Game.objects.all_with_orphaned()
filter_data = {"search": "cyberpunk,grand theft,portal"}
filterset = GameFilterSet(data=filter_data, queryset=queryset)

self.assertTrue(filterset.is_valid())
filtered_games = list(filterset.qs)
self.assertEqual(len(filtered_games), 3)

game_names = [game.name for game in filtered_games]
self.assertIn("Cyberpunk 2077", game_names)
self.assertIn("Grand Theft Auto V", game_names)
self.assertIn("Portal 2", game_names)
83 changes: 83 additions & 0 deletions games/api/__tests__/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,89 @@ def test_game_list_search_no_results(self):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)

def test_game_list_search_multiple_games_basic(self):
"""Test search with comma-separated names"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": "witcher,portal"}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)

game_names = [game["name"] for game in response.data]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Portal 2", game_names)

def test_game_list_search_multiple_games_case_insensitive(self):
"""Test search with mixed case comma-separated names"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": "WITCHER,cyberpunk"}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)

game_names = [game["name"] for game in response.data]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Cyberpunk 2077", game_names)

def test_game_list_search_multiple_games_partial_matching(self):
"""Test search with partial name matching"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": "cyber,port"}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)

game_names = [game["name"] for game in response.data]
self.assertIn("Cyberpunk 2077", game_names)
self.assertIn("Portal 2", game_names)

def test_game_list_search_multiple_games_with_whitespace(self):
"""Test search handles whitespace properly in comma-separated names"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": " witcher , cyberpunk "}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)

game_names = [game["name"] for game in response.data]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Cyberpunk 2077", game_names)

def test_game_list_search_multiple_games_some_no_matches(self):
"""Test search where some comma-separated names don't match"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": "witcher,nonexistent,portal"}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)

game_names = [game["name"] for game in response.data]
self.assertIn("The Witcher 3", game_names)
self.assertIn("Portal 2", game_names)

def test_game_list_search_multiple_games_all_games(self):
"""Test search that matches all games with comma-separated names"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": "witcher,cyberpunk,portal"}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 3)

def test_game_list_search_multiple_games_no_results(self):
"""Test search with comma-separated names that don't match anything"""
url = reverse("game-list-create-api")
response = self.client.get(
url, {"search": "nonexistent1,nonexistent2"}, **self._get_auth_headers()
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 0)

def test_create_game_with_all_required_fields(self):
url = reverse("game-list-create-api")
data = {
Expand Down
50 changes: 50 additions & 0 deletions games/api/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from django.db.models import Q

import django_filters

from ..models import Game


class EnhancedSearchFilter(django_filters.CharFilter):
"""
Enhanced search filter that allows searching for multiple games by name.
Accepts comma-separated game names and performs case-insensitive partial matching.
Also works for single game searches.
"""

def filter(self, qs, value):
if not value:
return qs

# Check if the search contains commas (multiple games)
if "," in value:
# Split the value by commas and strip whitespace
game_names = [name.strip() for name in value.split(",") if name.strip()]

if not game_names:
return qs

# Build Q objects for each game name using case-insensitive partial matching
q_objects = Q()
for name in game_names:
q_objects |= Q(name__icontains=name)

return qs.filter(q_objects)
else:
# Single game search - use the standard icontains lookup
return qs.filter(name__icontains=value)


class GameFilterSet(django_filters.FilterSet):
"""
FilterSet for Game model with enhanced search functionality.
"""

search = EnhancedSearchFilter(
field_name="name",
help_text="Search for games by name. Supports multiple comma-separated names (e.g., 'witcher,cyberpunk,portal')",
)

class Meta:
model = Game
fields = ["search"]
7 changes: 4 additions & 3 deletions games/api/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import generics, status
from rest_framework.filters import SearchFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from ..models import Game, GameOnPlatform, Platform, Vendor
from .filters import GameFilterSet
from .serializers import (
GameCreateSerializer,
GamePlatformCreateSerializer,
Expand All @@ -30,8 +31,8 @@ class VendorListAPIView(generics.ListAPIView):
class GameListCreateAPIView(generics.ListCreateAPIView):
queryset = Game.objects.all()
permission_classes = [IsAuthenticated]
filter_backends = [SearchFilter]
search_fields = ["name"]
filter_backends = [DjangoFilterBackend]
filterset_class = GameFilterSet

def get_serializer_class(self):
if self.request.method == "POST":
Expand Down
1 change: 1 addition & 0 deletions jewel/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"django_filters",
"django_extensions",
"games",
"steam",
Expand Down
35 changes: 29 additions & 6 deletions jewel/settings_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
https://docs.djangoproject.com/en/4.1/ref/settings/
"""

import os
from pathlib import Path

# Build paths inside the project like this: BASE_DIR / 'subdir'.
Expand All @@ -27,6 +28,7 @@

ALLOWED_HOSTS = [
"localhost",
"testserver",
]


Expand All @@ -39,8 +41,11 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"django_filters",
"django_extensions",
"games",
"steam",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -76,13 +81,17 @@

# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases

DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db_tests.sqlite3",
if DB_URL := os.environ.get("JEWEL_DATABASE_URL"):
import dj_database_url

DATABASES = {"default": dj_database_url.parse(DB_URL)}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db_tests.sqlite3",
}
}
}


# Password validation
Expand Down Expand Up @@ -125,3 +134,17 @@
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# API Configuration
API_TOKEN = "test-token"

# Django REST Framework
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"games.api.authentication.APITokenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
Loading