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(); });