diff --git a/ROADMAP.md b/ROADMAP.md index 3b63c88..ec6e099 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -145,7 +145,9 @@ Files now stage first and are resolved before entering the library. - [x] Profile pictures: staff Users page and a self-service Account picker (searchable library grid, remove supported) set avatars from library J-IDs - [x] Profile extras: per-user landing page, default rating filter/sort, items - per page and thumbnail size (synced to the account, applied on load) + per page and thumbnail size (synced to the account, applied on load), + plus e621 posts per page (48/100/200/320) for the Online browser and + pool loading - [x] Backend-less local mode: with no backend connected the SPA runs on the e621-facing pages only (Online, Pools) using credentials stored in the browser, and the shell offers a "Setup Backend" button instead diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 6ec6208..9935d1c 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -134,5 +134,7 @@ class PreferencesSerializer(serializers.Serializer): ordering = serializers.CharField(max_length=30, required=False) per_page = serializers.IntegerField(min_value=12, max_value=200, required=False) zoom = serializers.IntegerField(min_value=120, max_value=400, required=False) + # e621 renders at most 320 posts per request. + e621_per_page = serializers.IntegerField(min_value=12, max_value=320, required=False) # Open Online with the order:hot metatag when no search is given. online_hot_default = serializers.BooleanField(required=False) diff --git a/backend/apps/accounts/tests/test_preferences.py b/backend/apps/accounts/tests/test_preferences.py new file mode 100644 index 0000000..5c27ba9 --- /dev/null +++ b/backend/apps/accounts/tests/test_preferences.py @@ -0,0 +1,49 @@ +"""Per-user browse preferences: validation and merge semantics.""" + +import json + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + +from rest_framework.authtoken.models import Token + +User = get_user_model() + + +class PreferenceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="prefs-user", password="prefs-pass-123456" + ) + self.client = Client() + self.client.defaults["HTTP_AUTHORIZATION"] = ( + f"Token {Token.objects.create(user=self.user).key}" + ) + + def post(self, payload): + return self.client.post( + "/api/auth/preferences/", + data=json.dumps(payload), + content_type="application/json", + ) + + def test_e621_per_page_round_trips_within_range(self): + response = self.post({"e621_per_page": 320}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["e621_per_page"], 320) + self.assertEqual( + self.client.get("/api/auth/me/").json()["preferences"]["e621_per_page"], + 320, + ) + + def test_e621_per_page_rejects_out_of_range(self): + for value in (0, 8, 321, 1000): + self.assertEqual(self.post({"e621_per_page": value}).status_code, 400, value) + + def test_merge_keeps_other_keys(self): + self.post({"ratings": ["s"], "e621_per_page": 100}) + response = self.post({"per_page": 24}) + data = response.json() + self.assertEqual(data["ratings"], ["s"]) + self.assertEqual(data["e621_per_page"], 100) + self.assertEqual(data["per_page"], 24) diff --git a/backend/apps/follows/tests/__init__.py b/backend/apps/follows/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/follows/tests/test_follows_list.py b/backend/apps/follows/tests/test_follows_list.py new file mode 100644 index 0000000..8a6811a --- /dev/null +++ b/backend/apps/follows/tests/test_follows_list.py @@ -0,0 +1,78 @@ +"""The follow lists are complete sets, not pages. + +The SPA uses `GET /api/follows/tags/` (and pools) for two things: deciding a +follow toggle's state, and rendering every card on the Followed page. With the +standard 48-item pagination that silently broke past 48 follows — a 49th tag +could not be followed (the toggle never saw it) and the page hid it. These +tests pin the unpaginated behaviour. +""" + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase + +from rest_framework.authtoken.models import Token + +from apps.follows.models import FollowedPool, FollowedTag + +User = get_user_model() + + +class FollowListTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="follow-user", password="follow-pass-123456" + ) + self.other = User.objects.create_user( + username="follow-other", password="follow-pass-123456" + ) + self.client = self.api_client(self.user) + self.other_client = self.api_client(self.other) + + def api_client(self, user): + client = Client() + client.defaults["HTTP_AUTHORIZATION"] = ( + f"Token {Token.objects.create(user=user).key}" + ) + return client + + def test_tag_list_is_not_paginated_past_48(self): + FollowedTag.objects.bulk_create( + [ + FollowedTag(user=self.user, tag=f"tag_{index:03d}") + for index in range(60) + ] + ) + response = self.client.get("/api/follows/tags/") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsInstance(data, list, "the list must not be paginated") + self.assertEqual(len(data), 60) + self.assertEqual(data[0]["tag"], "tag_000") + self.assertEqual(data[-1]["tag"], "tag_059") + # The shape the SPA uses for its follow state. + self.assertIn("unseen_count", data[0]) + self.assertIn("id", data[0]) + + def test_pool_list_is_not_paginated(self): + FollowedPool.objects.bulk_create( + [ + FollowedPool(user=self.user, pool_id=59000 + index, name=f"pool {index}") + for index in range(55) + ] + ) + data = self.client.get("/api/follows/pools/").json() + self.assertIsInstance(data, list) + self.assertEqual(len(data), 55) + + def test_lists_only_contain_the_callers_follows(self): + FollowedTag.objects.create(user=self.user, tag="mine") + FollowedTag.objects.create(user=self.other, tag="theirs") + + mine = self.client.get("/api/follows/tags/").json() + theirs = self.other_client.get("/api/follows/tags/").json() + self.assertEqual([row["tag"] for row in mine], ["mine"]) + self.assertEqual([row["tag"] for row in theirs], ["theirs"]) + + def test_anonymous_cannot_list(self): + self.assertEqual(Client().get("/api/follows/tags/").status_code, 401) + self.assertEqual(Client().get("/api/follows/pools/").status_code, 401) diff --git a/backend/apps/follows/views.py b/backend/apps/follows/views.py index 6ec0299..adf5573 100644 --- a/backend/apps/follows/views.py +++ b/backend/apps/follows/views.py @@ -36,10 +36,16 @@ class FollowedTagViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet, ): - """Tags the current user follows.""" + """Tags the current user follows. + + Deliberately unpaginated: the SPA treats this as the complete set (follow + toggles read their state from it and the Followed page lists every card), + so a 48-item page silently broke following past 48 entries. + """ serializer_class = FollowedTagSerializer permission_classes = [IsAuthenticated] + pagination_class = None http_method_names = ["get", "post", "delete", "head", "options"] def get_queryset(self): @@ -112,10 +118,11 @@ class FollowedPoolViewSet( mixins.DestroyModelMixin, viewsets.GenericViewSet, ): - """Pools the current user follows.""" + """Pools the current user follows (unpaginated, like the tag list).""" serializer_class = FollowedPoolSerializer permission_classes = [IsAuthenticated] + pagination_class = None http_method_names = ["get", "post", "delete", "head", "options"] def get_queryset(self): diff --git a/frontend/src/features/account/PreferencesCard.tsx b/frontend/src/features/account/PreferencesCard.tsx index c070bd6..dced50a 100644 --- a/frontend/src/features/account/PreferencesCard.tsx +++ b/frontend/src/features/account/PreferencesCard.tsx @@ -9,6 +9,8 @@ import type { UserPreferences } from "@/lib/types"; import { useAuth } from "@/store/auth"; import { toast } from "@/store/toasts"; +const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320]; + const LANDING_OPTIONS: { value: NonNullable; label: string }[] = [ { value: "library", label: "Library" }, { value: "online", label: "Online" }, @@ -26,6 +28,7 @@ export function PreferencesCard() { per_page: user?.preferences?.per_page ?? 48, zoom: user?.preferences?.zoom ?? 190, online_hot_default: user?.preferences?.online_hot_default ?? true, + e621_per_page: user?.preferences?.e621_per_page ?? 48, }); const [saving, setSaving] = useState(false); @@ -149,8 +152,32 @@ export function PreferencesCard() { ))} + + + + +

+ e621 posts per page drives the Online browser and how many pool posts + load per request; e621 caps a request at 320. +

+