Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
309 lines
11 KiB
PHP
309 lines
11 KiB
PHP
<?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();
|
|
});
|