Add a public profile followers tab.
Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
function insertFollow(int $ownerId, int $followerId, ?\DateTimeInterface $at = null): void
|
||||
{
|
||||
DB::table('user_followers')->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();
|
||||
});
|
||||
Reference in New Issue
Block a user