From bf9ca12469c37cad04064622d7353a1d999de672 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 29 Aug 2026 12:25:49 +0200 Subject: [PATCH] Add a public profile followers tab. Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts. --- app/Http/Controllers/Api/FollowController.php | 60 ++-- .../Controllers/User/ProfileController.php | 62 +++- app/Services/FollowService.php | 85 +++++ resources/js/Pages/Profile/ProfileShow.jsx | 20 +- .../js/components/profile/ProfileTabs.jsx | 1 + .../components/profile/tabs/TabFollowers.jsx | 113 +++++++ resources/views/_legacy/profile.blade.php | 2 +- routes/web.php | 2 +- .../Profile/PublicFollowersPageTest.php | 308 ++++++++++++++++++ 9 files changed, 606 insertions(+), 47 deletions(-) create mode 100644 resources/js/components/profile/tabs/TabFollowers.jsx create mode 100644 tests/Feature/Profile/PublicFollowersPageTest.php diff --git a/app/Http/Controllers/Api/FollowController.php b/app/Http/Controllers/Api/FollowController.php index bd0d01d3..f1fcf561 100644 --- a/app/Http/Controllers/Api/FollowController.php +++ b/app/Http/Controllers/Api/FollowController.php @@ -5,12 +5,10 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\User; use App\Services\FollowService; -use App\Support\AvatarUrl; use App\Support\UsernamePolicy; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; /** * API endpoints for the follow system. @@ -72,26 +70,17 @@ final class FollowController extends Controller { $target = $this->resolveUser($username); $perPage = min((int) $request->query('per_page', 24), 100); + $page = max(1, (int) $request->query('page', 1)); - $rows = DB::table('user_followers as uf') - ->join('users as u', 'u.id', '=', 'uf.follower_id') - ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') - ->where('uf.user_id', $target->id) - ->whereNull('u.deleted_at') - ->orderByDesc('uf.created_at') - ->select([ - 'u.id', 'u.username', 'u.name', - 'up.avatar_hash', - 'uf.created_at as followed_at', - ]) - ->paginate($perPage) - ->through(fn ($row) => [ - 'id' => $row->id, - 'username' => $row->username, - 'display_name'=> $row->username ?? $row->name, - 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), - 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), - 'followed_at' => $row->followed_at, + $rows = $this->followService + ->paginatedFollowers((int) $target->id, $perPage, $page) + ->through(fn (array $row) => [ + 'id' => $row['id'], + 'username' => $row['username'], + 'display_name' => $row['display_name'], + 'avatar_url' => $row['avatar_url'], + 'profile_url' => $row['profile_url'], + 'followed_at' => $row['followed_at'], ]); return response()->json($rows); @@ -103,26 +92,17 @@ final class FollowController extends Controller { $target = $this->resolveUser($username); $perPage = min((int) $request->query('per_page', 24), 100); + $page = max(1, (int) $request->query('page', 1)); - $rows = DB::table('user_followers as uf') - ->join('users as u', 'u.id', '=', 'uf.user_id') - ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') - ->where('uf.follower_id', $target->id) - ->whereNull('u.deleted_at') - ->orderByDesc('uf.created_at') - ->select([ - 'u.id', 'u.username', 'u.name', - 'up.avatar_hash', - 'uf.created_at as followed_at', - ]) - ->paginate($perPage) - ->through(fn ($row) => [ - 'id' => $row->id, - 'username' => $row->username, - 'display_name'=> $row->username ?? $row->name, - 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50), - 'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)), - 'followed_at' => $row->followed_at, + $rows = $this->followService + ->paginatedFollowing((int) $target->id, $perPage, $page) + ->through(fn (array $row) => [ + 'id' => $row['id'], + 'username' => $row['username'], + 'display_name' => $row['display_name'], + 'avatar_url' => $row['avatar_url'], + 'profile_url' => $row['profile_url'], + 'followed_at' => $row['followed_at'], ]); return response()->json($rows); diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 1a516b4e..fceb80da 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -74,6 +74,7 @@ class ProfileController extends Controller 'stats', 'favourites', 'activity', + 'followers', ]; public function __construct( @@ -200,6 +201,28 @@ class ProfileController extends Controller return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301); } + /** + * Legacy "All followers" URL from old profile templates: + * GET /following/{id}/{slug?} → /@{canonical-username}/followers + */ + public function legacyFollowingById(Request $request, int $id, ?string $slug = null) + { + $user = User::query()->find($id); + + if (! $user) { + abort(404); + } + + $url = url('/@' . strtolower((string) $user->username) . '/followers'); + $query = $request->query(); + + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + + return redirect()->to($url, 301); + } + /** Toggle follow/unfollow for the profile of $username (auth required). */ public function toggleFollow(Request $request, string $username): JsonResponse { @@ -1293,9 +1316,37 @@ class ProfileController extends Controller ? ($profileTabUrls[$resolvedInitialTab] ?? $canonical) : $canonical; $tabMetaLabel = $resolvedInitialTab !== null - ? ucfirst($resolvedInitialTab) + ? ($resolvedInitialTab === 'followers' ? 'Followers' : ucfirst($resolvedInitialTab)) : null; + $followersListing = null; + + if (! $galleryOnly && $resolvedInitialTab === 'followers') { + $followersPage = max(1, (int) $request->query('page', 1)); + $followersListing = [ + 'data' => [], + 'current_page' => $followersPage, + 'last_page' => 1, + 'per_page' => 24, + 'total' => 0, + 'next_page_url' => null, + 'prev_page_url' => null, + ]; + + if (Schema::hasTable('user_followers')) { + $followersPaginator = $this->followService->paginatedFollowers((int) $user->id, 24, $followersPage); + $followersListing = [ + 'data' => array_values($followersPaginator->items()), + 'current_page' => $followersPaginator->currentPage(), + 'last_page' => $followersPaginator->lastPage(), + 'per_page' => $followersPaginator->perPage(), + 'total' => $followersPaginator->total(), + 'next_page_url' => $followersPaginator->nextPageUrl(), + 'prev_page_url' => $followersPaginator->previousPageUrl(), + ]; + } + } + $pageTitle = $galleryOnly ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') : ($isTabLanding @@ -1303,9 +1354,11 @@ class ProfileController extends Controller : (($user->username ?? $user->name ?? 'User') . ' on Skinbase')); $pageDescription = $galleryOnly ? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') - : ($isTabLanding - ? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.') - : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.')); + : ($resolvedInitialTab === 'followers' + ? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.') + : ($isTabLanding + ? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.') + : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.'))); $profileSeo = app(SeoFactory::class)->profilePage( $pageTitle, $galleryOnly ? $galleryUrl : $activeProfileUrl, @@ -1350,6 +1403,7 @@ class ProfileController extends Controller 'socialLinks' => $socialLinks, 'followerCount' => $followerCount, 'recentFollowers' => $recentFollowers->values(), + 'followersListing' => $followersListing, 'followContext' => $followContext, 'followAnalytics' => $followAnalytics, 'suggestedUsers' => $suggestedUsers, diff --git a/app/Services/FollowService.php b/app/Services/FollowService.php index 927822c0..4b6fd2c5 100644 --- a/app/Services/FollowService.php +++ b/app/Services/FollowService.php @@ -7,6 +7,7 @@ use App\Services\Activity\UserActivityService; use App\Events\Achievements\AchievementCheckRequested; use App\Services\FollowAnalyticsService; use App\Support\AvatarUrl; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -160,6 +161,38 @@ final class FollowService ->value('followers_count'); } + /** + * People who follow $userId (user_id = the profile owner being followed). + * + * @return LengthAwarePaginator> + */ + public function paginatedFollowers(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator + { + return $this->paginateFollowRelationship( + ownerColumn: 'uf.user_id', + relatedUserColumn: 'uf.follower_id', + ownerId: $userId, + perPage: $perPage, + page: $page, + ); + } + + /** + * People $userId follows (follower_id = the profile owner). + * + * @return LengthAwarePaginator> + */ + public function paginatedFollowing(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator + { + return $this->paginateFollowRelationship( + ownerColumn: 'uf.follower_id', + relatedUserColumn: 'uf.user_id', + ownerId: $userId, + perPage: $perPage, + page: $page, + ); + } + public function followingCount(int $userId): int { return (int) DB::table('user_statistics') @@ -216,6 +249,58 @@ final class FollowService // ─── Private helpers ───────────────────────────────────────────────────── + /** + * @return LengthAwarePaginator> + */ + private function paginateFollowRelationship( + string $ownerColumn, + string $relatedUserColumn, + int $ownerId, + int $perPage, + int $page, + ): LengthAwarePaginator { + $perPage = max(1, min($perPage, 100)); + $page = max(1, $page); + + return DB::table('user_followers as uf') + ->join('users as u', 'u.id', '=', $relatedUserColumn) + ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') + ->where($ownerColumn, $ownerId) + ->whereNull('u.deleted_at') + ->orderByDesc('uf.created_at') + ->select([ + 'u.id', + 'u.username', + 'u.name', + 'u.level', + 'u.rank', + 'up.avatar_hash', + 'uf.created_at as followed_at', + ]) + ->paginate($perPage, ['*'], 'page', $page) + ->through(fn ($row) => $this->mapPublicFollowUser($row)); + } + + /** + * @return array + */ + private function mapPublicFollowUser(object $row): array + { + $username = (string) ($row->username ?? ''); + + return [ + 'id' => (int) $row->id, + 'username' => $username, + 'name' => (string) ($row->name ?? $username), + 'display_name' => $username !== '' ? $username : (string) ($row->name ?? ''), + 'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 50), + 'profile_url' => '/@' . strtolower($username !== '' ? $username : (string) $row->id), + 'followed_at' => $row->followed_at, + 'level' => (int) ($row->level ?? 1), + 'rank' => (string) ($row->rank ?? 'Newbie'), + ]; + } + private function incrementCounter(int $userId, string $column): void { DB::table('user_statistics')->updateOrInsert( diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index 40f62597..3d14dae3 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -13,9 +13,10 @@ import TabActivity from '../../components/profile/tabs/TabActivity' import TabPosts from '../../components/profile/tabs/TabPosts' import TabStories from '../../components/profile/tabs/TabStories' import TabWorlds from '../../components/profile/tabs/TabWorlds' +import TabFollowers from '../../components/profile/tabs/TabFollowers' import GroupProfileSummary from '../../components/groups/GroupProfileSummary' -const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity'] +const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers'] function getInitialTab(initialTab = 'posts') { if (typeof window === 'undefined') { @@ -55,6 +56,7 @@ export default function ProfileShow() { socialLinks, followerCount, recentFollowers, + followersListing, followContext, followAnalytics, suggestedUsers, @@ -87,6 +89,15 @@ export default function ProfileShow() { const handleTabChange = useCallback((tab) => { if (!VALID_TABS.includes(tab)) return + + // Followers listing is loaded only on the followers tab GET. A client-only + // pushState would render an empty list, so navigate for a real page load. + if (tab === 'followers') { + const targetBase = profileTabUrls?.followers || `${profileUrl || ''}/followers` + window.location.assign(targetBase) + return + } + setActiveTab(tab) try { @@ -274,6 +285,13 @@ export default function ProfileShow() { creatorStories={creatorStories} /> )} + {activeTab === 'followers' && ( + + )} diff --git a/resources/js/components/profile/ProfileTabs.jsx b/resources/js/components/profile/ProfileTabs.jsx index 84336810..71962d9e 100644 --- a/resources/js/components/profile/ProfileTabs.jsx +++ b/resources/js/components/profile/ProfileTabs.jsx @@ -11,6 +11,7 @@ export const TABS = [ { id: 'stats', label: 'Stats', icon: 'fa-chart-bar' }, { id: 'favourites', label: 'Favourites', icon: 'fa-heart' }, { id: 'activity', label: 'Activity', icon: 'fa-bolt' }, + { id: 'followers', label: 'Followers', icon: 'fa-users' }, ] /** diff --git a/resources/js/components/profile/tabs/TabFollowers.jsx b/resources/js/components/profile/tabs/TabFollowers.jsx new file mode 100644 index 00000000..2a57b30c --- /dev/null +++ b/resources/js/components/profile/tabs/TabFollowers.jsx @@ -0,0 +1,113 @@ +import React from 'react' + +function formatFollowedAt(value) { + if (!value) return null + + try { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } catch { + return null + } +} + +export default function TabFollowers({ listing, username, followerCount }) { + const items = Array.isArray(listing?.data) ? listing.data : [] + const total = Number(listing?.total ?? followerCount ?? 0) + const currentPage = Number(listing?.current_page ?? 1) + const lastPage = Number(listing?.last_page ?? 1) + const prevUrl = listing?.prev_page_url ?? null + const nextUrl = listing?.next_page_url ?? null + + return ( +
+

+ + Followers + + {total.toLocaleString()} + +

+ + {items.length === 0 ? ( +

+ {username ? `${username} has no public followers yet.` : 'No public followers yet.'} +

+ ) : ( + + )} + + {lastPage > 1 ? ( + + ) : null} +
+ ) +} diff --git a/resources/views/_legacy/profile.blade.php b/resources/views/_legacy/profile.blade.php index 5573fd2f..5d4002b7 100644 --- a/resources/views/_legacy/profile.blade.php +++ b/resources/views/_legacy/profile.blade.php @@ -556,7 +556,7 @@ {{ number_format($followerCount) }} - All diff --git a/routes/web.php b/routes/web.php index ee27ec67..5e27f5b3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -493,7 +493,7 @@ Route::get('/@{username}/collections/{slug}', [ProfileCollectionController::clas Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername']) ->where('username', '[A-Za-z0-9_-]{3,20}') - ->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity') + ->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity|followers') ->name('profile.tab'); Route::get('/@{username}', [ProfileController::class, 'showByUsername']) diff --git a/tests/Feature/Profile/PublicFollowersPageTest.php b/tests/Feature/Profile/PublicFollowersPageTest.php new file mode 100644 index 00000000..259b6a46 --- /dev/null +++ b/tests/Feature/Profile/PublicFollowersPageTest.php @@ -0,0 +1,308 @@ +insert([ + 'user_id' => $ownerId, + 'follower_id' => $followerId, + 'created_at' => $at ?? now(), + ]); +} + +it('renders the public profile followers page for an existing username', function () { + $owner = User::factory()->create(['username' => 'followowner']); + $follower = User::factory()->create(['username' => 'realfollower']); + $ownerFollows = User::factory()->create(['username' => 'ownerfollows']); + + insertFollow((int) $owner->id, (int) $follower->id); + insertFollow((int) $ownerFollows->id, (int) $owner->id); + + $this->get('/@followowner/followers') + ->assertOk() + ->assertSee('http://skinbase26.test/@followowner/followers', false) + ->assertSee('followowner Followers on Skinbase', false) + ->assertSee('People who follow followowner on Skinbase.', false) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Profile/ProfileShow') + ->where('initialTab', 'followers') + ->where('profileTabUrls.followers', url('/@followowner/followers')) + ->where('followersListing.total', 1) + ->where('followersListing.data.0.username', 'realfollower') + ->where('followersListing.data.0.profile_url', '/@realfollower') + ->missing('followersListing.data.0.email') + ->has('profileTabUrls.posts') + ->has('profileTabUrls.artworks')); +}); + +it('allows anonymous viewers to load public followers without authentication', function () { + $owner = User::factory()->create(['username' => 'anonowner']); + + $this->get('/@anonowner/followers')->assertOk(); + $this->assertGuest(); +}); + +it('does not return people the owner follows as followers', function () { + $owner = User::factory()->create(['username' => 'semanticowner']); + $follower = User::factory()->create(['username' => 'semanticfollower']); + $following = User::factory()->create(['username' => 'semanticfollowing']); + + insertFollow((int) $owner->id, (int) $follower->id); + insertFollow((int) $following->id, (int) $owner->id); + + $this->get('/@semanticowner/followers') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('followersListing.total', 1) + ->where('followersListing.data.0.username', 'semanticfollower') + ->where('followersListing.data.0.username', fn ($username) => $username !== 'semanticfollowing')); +}); + +it('normalizes mixed-case usernames to the lowercase canonical followers URL', function () { + User::factory()->create(['username' => 'CaseOwner']); + + $this->get('/@CaseOwner/followers') + ->assertStatus(301) + ->assertRedirect('/@caseowner/followers'); +}); + +it('follows username_redirects to the current followers URL', function () { + $user = User::factory()->create(['username' => 'newfollowuser']); + + DB::table('username_redirects')->insert([ + 'old_username' => 'oldfollowuser', + 'new_username' => 'newfollowuser', + 'user_id' => $user->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->get('/@oldfollowuser/followers') + ->assertStatus(301) + ->assertRedirect('/@newfollowuser/followers'); +}); + +it('returns 404 for a nonexistent public followers username', function () { + $this->get('/@missinguserzz/followers')->assertNotFound(); +}); + +it('paginates public followers', function () { + $owner = User::factory()->create(['username' => 'pageowner']); + $followers = User::factory()->count(25)->create(); + + foreach ($followers as $index => $follower) { + insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index)); + } + + $this->get('/@pageowner/followers') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('followersListing.per_page', 24) + ->where('followersListing.current_page', 1) + ->where('followersListing.last_page', 2) + ->where('followersListing.total', 25) + ->has('followersListing.data', 24)); + + $this->get('/@pageowner/followers?page=2') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('followersListing.current_page', 2) + ->has('followersListing.data', 1)); +}); + +it('redirects legacy following id and slug to the canonical followers URL', function () { + $owner = User::factory()->create(['username' => 'legacyfollow']); + + $this->get('/following/' . $owner->id . '/old-outdated-slug') + ->assertStatus(301) + ->assertRedirect('/@legacyfollow/followers'); +}); + +it('ignores a mismatched legacy slug and uses the current database username', function () { + $owner = User::factory()->create(['username' => 'canonicalname']); + + $this->get('/following/' . $owner->id . '/wrong-slug') + ->assertStatus(301) + ->assertRedirect('/@canonicalname/followers'); +}); + +it('returns 404 for a legacy following URL when the user id does not exist', function () { + $this->get('/following/99999999/whatever')->assertNotFound(); +}); + +it('preserves query strings when redirecting legacy following URLs', function () { + $owner = User::factory()->create(['username' => 'qsowner']); + + $this->get('/following/' . $owner->id . '/old-slug?page=2&utm_source=legacy') + ->assertStatus(301) + ->assertRedirect('/@qsowner/followers?page=2&utm_source=legacy'); +}); + +it('no longer emits the broken legacy following URL from the old profile template', function () { + $blade = file_get_contents(resource_path('views/_legacy/profile.blade.php')); + + expect($blade)->not->toContain('/following/{{ $user->id }}'); + expect($blade)->toContain("/@' . strtolower((string) (\$user->username ?? \$uname)) . '/followers"); +}); + +it('leaves authenticated /following root behavior unchanged', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/following') + ->assertRedirect(route('dashboard.following')); +}); + +it('leaves dashboard followers as the authenticated owner surface', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/dashboard/followers') + ->assertOk(); +}); + +it('leaves studio followers as the creator-owner surface', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/studio/followers') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioFollowers')); +}); + +it('leaves the public followers API payload shape unchanged', function () { + $owner = User::factory()->create(['username' => 'apiowner']); + $follower = User::factory()->create(['username' => 'apifollower']); + $following = User::factory()->create(['username' => 'apifollowing']); + + insertFollow((int) $owner->id, (int) $follower->id); + insertFollow((int) $following->id, (int) $owner->id); + + $this->getJson('/api/user/apiowner/followers') + ->assertOk() + ->assertJsonPath('data.0.username', 'apifollower') + ->assertJsonPath('data.0.profile_url', '/@apifollower') + ->assertJsonMissingPath('data.0.email') + ->assertJsonMissing(['username' => 'apifollowing']); +}); + +it('leaves other public profile tabs on their canonical routes', function () { + $owner = User::factory()->create(['username' => 'tabowner']); + + $this->get('/@tabowner/artworks') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', 'artworks') + ->where('profileTabUrls.followers', url('/@tabowner/followers')) + ->where('profileTabUrls.artworks', url('/@tabowner/artworks'))); +}); + +function profileSqlLooksLikePaginatedFollowers(string $sql): bool +{ + $normalized = strtolower($sql); + + if (! str_contains($normalized, 'user_followers')) { + return false; + } + + if (! str_contains($normalized, ' as uf') && ! str_contains($normalized, ' as "uf"')) { + return false; + } + + $isPaginatorCount = str_contains($normalized, 'aggregate') + && str_contains($normalized, 'join'); + + $isPageRows = str_contains($normalized, 'limit') + && (str_contains($normalized, 'limit 24') || str_contains($normalized, 'limit ?')); + + return $isPaginatorCount || $isPageRows; +} + +it('does not load paginated followers on the base profile, artworks, or about tabs', function () { + $owner = User::factory()->create(['username' => 'lazyowner']); + $followers = User::factory()->count(3)->create(); + + foreach ($followers as $index => $follower) { + insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index)); + } + + foreach (['/@lazyowner', '/@lazyowner/artworks', '/@lazyowner/about'] as $url) { + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->get($url) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('followersListing', null) + ->has('followerCount') + ->has('recentFollowers') + ->has('viewerIsFollowing') + ->has('followAnalytics')); + + $paginatedQueries = collect(DB::getQueryLog()) + ->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query'])) + ->values(); + + expect($paginatedQueries)->toBeEmpty('paginated followers query ran on '.$url); + } + + DB::disableQueryLog(); +}); + +it('loads paginated followers only on the followers tab including page=2', function () { + $owner = User::factory()->create(['username' => 'lazyfollow']); + $followers = User::factory()->count(25)->create(); + + foreach ($followers as $index => $follower) { + insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index)); + } + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->get('/@lazyfollow/followers?page=2') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', 'followers') + ->where('followersListing.current_page', 2) + ->where('followersListing.per_page', 24) + ->where('followersListing.total', 25) + ->has('followersListing.data', 1) + ->has('followerCount') + ->has('recentFollowers')); + + $paginatedQueries = collect(DB::getQueryLog()) + ->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query'])) + ->values(); + + expect($paginatedQueries)->not->toBeEmpty(); + + DB::disableQueryLog(); +}); + +it('ignores ?page= on unrelated profile tabs and still skips followers pagination', function () { + $owner = User::factory()->create(['username' => 'pageignore']); + insertFollow((int) $owner->id, (int) User::factory()->create()->id); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $this->get('/@pageignore/artworks?page=2') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('initialTab', 'artworks') + ->where('followersListing', null)); + + $paginatedQueries = collect(DB::getQueryLog()) + ->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query'])) + ->values(); + + expect($paginatedQueries)->toBeEmpty(); + + DB::disableQueryLog(); +});