Fix following past 48 entries and make the e621 page size configurable

Follow lists were paginated at the API default of 48, but the SPA treats
them as complete sets: the tag/pool toggles read their state from page one
(so the 49th follow looked unfollowed and its spinner waited for a page that
could never contain it) and the Followed page rendered only 48 cards while
showing that as the count. Both follow endpoints are now unpaginated — they
are per-user sets and still restricted to the caller's rows — and the three
consumers take plain arrays.

Post visibility: the old J621-Django online view fetched limit=320 (e621's
maximum) while ours hard-coded 48, and fetchPostsByIds capped id batches at
100. The Online browser now has a 'Posts per page' setting (48/100/200/320)
in its sidebar, mirrored in Account -> Browsing preferences, stored per user
as e621_per_page and also used for pool loading; the id-batch cap is raised
to 320.

Tests: follow list shape/isolation (4) and preference validation/merge (3)
added; the full backend suite is 46 green. Live-checked the array response
shape and the preference bounds (200 accepted, 500 rejected).
This commit is contained in:
2026-09-18 19:06:18 -05:00
parent d16a77907a
commit 1adb761c8d
16 changed files with 237 additions and 25 deletions
+2
View File
@@ -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)
@@ -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)
@@ -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)
+9 -2
View File
@@ -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):