Files
SkinbaseNova/tests/Feature/Profile/PublicFollowersPageTest.php
T
klevze bf9ca12469 Add a public profile followers tab.
Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
2026-08-29 12:25:49 +02:00

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