diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3037900d5..e7d7a24d3 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -133,7 +133,9 @@ Sources: [organization page](resources/js/pages/organization/show.tsx), [commits Signed in, you can open the address of anyone on GitHub, like `/@octocat`, even if they never signed up. The first time, the page looks the handle up on GitHub while you wait, usually a few seconds, then shows what it found: a developer's page, or an organization's. Each member can look up to 30 handles an hour, since lookups share GitHub's limit with everyone; when that limit runs low, the page says when lookups resume. A handle that is no GitHub account says so, and isn't asked about again for a day. Guests and search engines can't look anyone up: they're asked to sign in. -A developer who isn't here yet gets a page with their GitHub avatar, name, handle, bio and a link to GitHub, marked **Not on /dev/null yet**, but never their location or website. Anyone with the link can open it, and it shares as a card, but search engines are asked to leave it out. Signing in with that GitHub account makes the page yours: guests see an **I am @handle** button for that. If you delete your account, your page doesn't come back as one of these, until you sign in again. +A developer who isn't here yet gets a page with their GitHub avatar, name, handle, bio and a link to GitHub, marked **Not on /dev/null yet**, but never their location or website. Anyone with the link can open it, and it shares as a card, but search engines are asked to leave it out. Signing in with that GitHub account makes the page yours: guests see an **I am @handle** button for that. + +Its page shows how many members are waiting to follow them: the members who followed them on GitHub when they were looked up, and the ones who clicked **Follow** on their page, which then reads **Waiting** and can be clicked again to stop waiting. Members also see the people they follow among them. When the developer signs up, everyone waiting follows them, and their notifications show those follows like any others. Who you wait to follow is in your data export, and goes when you delete your account. If you delete your account, your page doesn't come back as one of these, until you sign in again. Sources: [looking handles up](app/Http/Controllers/ProfileLookupController.php), [lookup page](resources/js/pages/profile/lookup.tsx), [developer page](resources/js/pages/profile/unclaimed.tsx), [GitHub lookups](app/Actions/CreateGitHubProfile.php). diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 289e5abb1..acab06411 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -51,7 +51,7 @@ * dismissed_suggestions: Generator, * organizations: array, * followed_organizations: array, - * pending_organization_follows: list, + * pending_follows: list, * messages: Generator, * reports: Traversable>, * remote_pins: array, @@ -74,7 +74,7 @@ public function handle(User $user): array { return [ - 'schema_version' => 5, + 'schema_version' => 6, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -123,7 +123,7 @@ public function handle(User $user): array 'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()), 'organizations' => $this->organizations($user), 'followed_organizations' => $this->followedOrganizations($user), - 'pending_organization_follows' => $this->pendingOrganizationFollows($user), + 'pending_follows' => $this->pendingFollows($user), 'messages' => $this->messages($user), 'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [ 'commit_id' => $report->commit_id, @@ -397,14 +397,14 @@ private function followedOrganizations(User $user): array } /** - * The GitHub IDs of the organizations the user follows on GitHub, to follow here once they are on /dev/null. + * The GitHub IDs of the accounts the user waits to follow until they are on /dev/null: organizations and developers. * * @return list */ - private function pendingOrganizationFollows(User $user): array + private function pendingFollows(User $user): array { /** @var list */ - return DB::table('pending_organization_follows')->where('user_id', $user->id)->orderBy('github_id')->pluck('github_id')->all(); + return DB::table('pending_follows')->where('user_id', $user->id)->orderBy('github_id')->pluck('github_id')->all(); } /** diff --git a/app/Actions/CreateFollowsFromPendingFollows.php b/app/Actions/CreateFollowsFromPendingFollows.php new file mode 100644 index 000000000..a358962c0 --- /dev/null +++ b/app/Actions/CreateFollowsFromPendingFollows.php @@ -0,0 +1,34 @@ +where('github_id', $user->github_id); + $followers = User::query()->whereIn('id', $pending->clone()->select('user_id'))->get(); + + $user->followers()->attach($followers->modelKeys()); + + foreach ($followers as $follower) { + $this->createFollowNotification->handle($follower, $user); + } + + $pending->delete(); + } +} diff --git a/app/Actions/CreateGitHubProfile.php b/app/Actions/CreateGitHubProfile.php index 9f0d3ddc0..6063fc91c 100644 --- a/app/Actions/CreateGitHubProfile.php +++ b/app/Actions/CreateGitHubProfile.php @@ -25,9 +25,9 @@ public function __construct( /** * Gives the handle a page from its GitHub account: an organization's, or an unclaimed developer's, which keeps only - * what the page shows. A user here who renamed on GitHub moves to the handle. A handle that is no GitHub account, - * or the account of someone who deleted theirs here, is remembered for a day, so the page says so without asking - * GitHub again. + * what the page shows, with the members following them on GitHub waiting to follow them here. A user here who + * renamed on GitHub moves to the handle. A handle that is no GitHub account, or the account of someone who deleted + * theirs here, is remembered for a day, so the page says so without asking GitHub again. */ public function handle(string $handle): void { @@ -50,7 +50,12 @@ public function handle(string $handle): void $profile = GitHubProfile::fromAccount($account); - DB::transaction(function () use ($profile, $githubId): void { + // Asked before the profile exists, so a sign-in can't come between them and miss the members waiting... + $followerIds = Profile::query()->where('github_id', $githubId)->exists() + ? [] + : $this->github->followerIds($profile->handle, CreateUserGitHubFollows::MaxPages); + + DB::transaction(function () use ($profile, $githubId, $followerIds): void { $this->updateStaleHandles->handle($profile->handle, $githubId); $existing = Profile::query()->where('type', ProfileType::User)->firstWhere('github_id', $githubId); @@ -69,6 +74,12 @@ public function handle(string $handle): void 'bio' => $profile->bio, 'github_synced_at' => now(), ]); + + // Those still waiting from before the profile last went are kept... + DB::table('pending_follows')->insertOrIgnoreUsing( + ['user_id', 'github_id', 'created_at'], + User::query()->whereIn('github_id', $followerIds)->select('id')->selectRaw('?, ?', [$githubId, now()]), + ); }); } } diff --git a/app/Actions/CreatePendingFollow.php b/app/Actions/CreatePendingFollow.php new file mode 100644 index 000000000..313b3e417 --- /dev/null +++ b/app/Actions/CreatePendingFollow.php @@ -0,0 +1,28 @@ +lockForUpdate()->find($profile->id)?->isUnclaimed() !== true) { + return; + } + + DB::table('pending_follows')->insertOrIgnore(['user_id' => $user->id, 'github_id' => $profile->github_id, 'created_at' => now()]); + }); + } +} diff --git a/app/Actions/CreateUserGitHubFollows.php b/app/Actions/CreateUserGitHubFollows.php index fc5473fec..6cb5399d5 100644 --- a/app/Actions/CreateUserGitHubFollows.php +++ b/app/Actions/CreateUserGitHubFollows.php @@ -69,7 +69,7 @@ public function handle(User $user, bool $notify = true): void $user->followedOrganizations()->syncWithoutDetaching($organizations->modelKeys()); - DB::table('pending_organization_follows')->insertOrIgnore($organizationIds + DB::table('pending_follows')->insertOrIgnore($organizationIds ->reject(fn (string $id): bool => $organizations->contains('github_id', $id)) ->map(fn (string $id): array => ['user_id' => $user->id, 'github_id' => $id, 'created_at' => now()]) ->all()); diff --git a/app/Actions/DeletePendingFollow.php b/app/Actions/DeletePendingFollow.php new file mode 100644 index 000000000..95151f292 --- /dev/null +++ b/app/Actions/DeletePendingFollow.php @@ -0,0 +1,20 @@ +where('user_id', $user->id)->where('github_id', $profile->github_id)->delete(); + } +} diff --git a/app/Actions/UpdateOrCreateOrganization.php b/app/Actions/UpdateOrCreateOrganization.php index 4f247ca76..3d327a200 100644 --- a/app/Actions/UpdateOrCreateOrganization.php +++ b/app/Actions/UpdateOrCreateOrganization.php @@ -52,7 +52,7 @@ public function handle(array $account): Organization $organization = Organization::query()->firstOrCreate(['id' => $organizationProfile->id]); if ($organization->wasRecentlyCreated) { - $pending = DB::table('pending_organization_follows')->where('github_id', (string) $githubId); + $pending = DB::table('pending_follows')->where('github_id', (string) $githubId); DB::table('organization_follows')->insertUsing( ['user_id', 'organization_id', 'created_at'], diff --git a/app/Actions/UpdateOrCreateUser.php b/app/Actions/UpdateOrCreateUser.php index 416a90308..04df6f635 100644 --- a/app/Actions/UpdateOrCreateUser.php +++ b/app/Actions/UpdateOrCreateUser.php @@ -14,14 +14,18 @@ final readonly class UpdateOrCreateUser { - public function __construct(private GitHubProvider $github, private UpdateStaleHandles $updateStaleHandles) - { + public function __construct( + private GitHubProvider $github, + private UpdateStaleHandles $updateStaleHandles, + private CreateFollowsFromPendingFollows $createFollowsFromPendingFollows, + ) { // } /** * Signing in makes the GitHub account's profile a user's: an unclaimed one, looked up from GitHub before, becomes - * theirs with its ID. Someone who deleted their account and comes back is no longer remembered as gone. + * theirs with its ID, and the members waiting for them follow them. Someone who deleted their account and comes back + * is no longer remembered as gone. */ public function handle(GitHubUser $githubUser): User { @@ -46,7 +50,7 @@ public function handle(GitHubUser $githubUser): User DB::table('deleted_github_accounts')->where('github_id', $githubId)->delete(); - return User::query()->updateOrCreate([ + $user = User::query()->updateOrCreate([ 'id' => $id, 'github_id' => $githubId, ], [ @@ -54,6 +58,12 @@ public function handle(GitHubUser $githubUser): User 'email' => $email === '' ? null : $email, ...($hasSponsorsListing === null ? [] : ['has_sponsors_listing' => $hasSponsorsListing]), ]); + + if ($user->wasRecentlyCreated) { + $this->createFollowsFromPendingFollows->handle($user); + } + + return $user; }); } } diff --git a/app/Actions/UpdateStaleHandles.php b/app/Actions/UpdateStaleHandles.php index 21b9e7184..01497cd13 100644 --- a/app/Actions/UpdateStaleHandles.php +++ b/app/Actions/UpdateStaleHandles.php @@ -13,7 +13,7 @@ * placeholder starts with an underscore, which no GitHub handle can, and their next sync restores a real one. * Bots keep their identity under reserved handles so they can continue replying. An unclaimed profile, a developer * looked up from GitHub who never signed in, has nothing of its own, so it goes: looking its new handle up brings it - * back. + * back, with the members waiting for it, kept by its GitHub ID. */ public function handle(string $handle, string $githubId): void { diff --git a/app/Http/Controllers/PendingFollowController.php b/app/Http/Controllers/PendingFollowController.php new file mode 100644 index 000000000..34685daa5 --- /dev/null +++ b/app/Http/Controllers/PendingFollowController.php @@ -0,0 +1,34 @@ +handle($user, $profile); + + return back(); + } + + public function destroy(DeletePendingFollowRequest $request, #[CurrentUser] User $user, Profile $profile, DeletePendingFollow $action): RedirectResponse + { + $action->handle($user, $profile); + + return back(); + } +} diff --git a/app/Http/Controllers/UnclaimedProfileController.php b/app/Http/Controllers/UnclaimedProfileController.php index e0e9f6596..7af73204b 100644 --- a/app/Http/Controllers/UnclaimedProfileController.php +++ b/app/Http/Controllers/UnclaimedProfileController.php @@ -10,6 +10,7 @@ use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -17,8 +18,8 @@ { /** * A developer looked up from GitHub who isn't on /dev/null yet has a page at their "@handle", with what GitHub - * shows of them but where they are, for anyone with the link, but kept out of search engines. Signing in as them - * makes it theirs. Like users' pages, it moves for good to the handle's own case. + * shows of them but where they are, for anyone with the link, but kept out of search engines, with how many members + * wait to follow them. Signing in as them makes it theirs. Like users' pages, it moves for good to the handle's own case. */ public function show(ShowUnclaimedProfileRequest $request, #[CurrentUser] ?User $currentUser, PageMetadata $metadata): RedirectResponse|Response { @@ -32,8 +33,20 @@ public function show(ShowUnclaimedProfileRequest $request, #[CurrentUser] ?User return redirect(route('user.show', $profile->handle).($query === '' ? '' : "?{$query}"), 301, ['Cache-Control' => 'max-age=86400, private']); } + $waiting = DB::table('pending_follows')->where('github_id', $profile->github_id); + return Inertia::render('profile/unclaimed', [ - 'profile' => $profile->only(['handle', 'name', 'avatar_url', 'bio']), + 'profile' => $profile->only(['id', 'handle', 'name', 'avatar_url', 'bio']), + 'waitingCount' => fn (): int => $waiting->clone()->count(), + 'isWaiting' => fn (): bool => $currentUser instanceof User && $waiting->clone()->where('user_id', $currentUser->id)->exists(), + // Who follows whom is for members, so only they see who is waiting, among the people they follow... + 'waitingYouFollow' => fn (): array => $currentUser instanceof User ? $currentUser->following() + ->whereIn('users.id', $waiting->clone()->select('user_id')) + ->orderBy('users.id') + ->limit(3) + ->get() + ->map(fn (User $user): array => $user->only(['id', 'handle', 'name', 'avatar_url'])) + ->all() : [], ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->unclaimedProfile($profile)]); } } diff --git a/app/Http/Requests/CreatePendingFollowRequest.php b/app/Http/Requests/CreatePendingFollowRequest.php new file mode 100644 index 000000000..c5384c19a --- /dev/null +++ b/app/Http/Requests/CreatePendingFollowRequest.php @@ -0,0 +1,31 @@ +route('profile'); + assert($profile instanceof Profile); + + return $profile->isUnclaimed() ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeletePendingFollowRequest.php b/app/Http/Requests/DeletePendingFollowRequest.php new file mode 100644 index 000000000..d8462db53 --- /dev/null +++ b/app/Http/Requests/DeletePendingFollowRequest.php @@ -0,0 +1,31 @@ +route('profile'); + assert($profile instanceof Profile); + + return $profile->isUnclaimed() ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php b/database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php new file mode 100644 index 000000000..00270351c --- /dev/null +++ b/database/migrations/2026_11_02_130000_rename_pending_organization_follows_to_pending_follows.php @@ -0,0 +1,27 @@ +, <> diff --git a/resources/js/pages/profile/unclaimed.tsx b/resources/js/pages/profile/unclaimed.tsx index fffc8d19f..771803bc7 100644 --- a/resources/js/pages/profile/unclaimed.tsx +++ b/resources/js/pages/profile/unclaimed.tsx @@ -1,14 +1,22 @@ -import { Head, usePage } from '@inertiajs/react'; +import { Head, router, usePage } from '@inertiajs/react'; +import { Clock } from 'lucide-react'; +import { useState } from 'react'; +import PendingFollowController from '@/actions/App/Http/Controllers/PendingFollowController'; import { show } from '@/actions/App/Http/Controllers/UserController'; import { BioText } from '@/components/bio-text'; +import { useFollowHover } from '@/components/follow-button'; import { GitHubIcon } from '@/components/github-icon'; import { ProfileShareButton } from '@/components/profile-share-button'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import AppLayout from '@/layouts/app-layout'; +import { exactCountTitle, formatCount } from '@/lib/numbers'; +import { toastWhenThrottled } from '@/lib/throttle'; import { login } from '@/routes'; +import type { User } from '@/types'; type UnclaimedProfile = { + id: string; handle: string; name: string; avatar_url: string | null; @@ -21,10 +29,19 @@ type UnclaimedProfile = { */ export default function UnclaimedProfile({ profile, + waitingCount, + isWaiting, + waitingYouFollow, }: { profile: UnclaimedProfile; + waitingCount: number; + isWaiting: boolean; + waitingYouFollow: Pick[]; }) { const { auth } = usePage().props; + const [isWaitingNow, setIsWaitingNow] = useState(isWaiting); + const shownWaitingCount = + waitingCount + Number(isWaitingNow) - Number(isWaiting); return ( <> @@ -52,13 +69,19 @@ export default function UnclaimedProfile({ handle={profile.handle} href={show(profile.handle).url} /> - {auth.user === null && ( + {auth.user === null ? ( + ) : ( + )} @@ -92,6 +115,44 @@ export default function UnclaimedProfile({ + + {shownWaitingCount > 0 && ( +
+ {waitingYouFollow.length > 0 && ( + + {waitingYouFollow.map((user) => ( + + ))} + + )} +
+

+ + {formatCount(shownWaitingCount)} + {' '} + + {shownWaitingCount === 1 + ? 'member waiting to follow' + : 'members waiting to follow'} + {waitingYouFollow.length > 0 && + `, including ${new Intl.ListFormat('en').format(waitingYouFollow.map((user) => `@${user.handle}`))}`} + +

+

+ They'll follow as soon as @{profile.handle}{' '} + signs up. +

+
+
+ )}
@@ -108,3 +169,91 @@ export default function UnclaimedProfile({ } UnclaimedProfile.layout = AppLayout; + +/** + * Following a developer who isn't here yet waits for them to sign up. It shows at once, and comes back if the server + * refuses it. Right after a click, it never turns into cancelling under the pointer. + */ +function WaitButton({ + profile, + isWaiting, + onToggle, +}: { + profile: UnclaimedProfile; + isWaiting: boolean; + onToggle: (isWaiting: boolean) => void; +}) { + const { isJustToggled, markToggled, onMouseLeave } = useFollowHover(); + // Each click waits for the one before it to answer, so a double click can't follow and then stop. + const [isPending, setIsPending] = useState(false); + + const toggle = () => { + if (isPending) { + return; + } + + const waits = !isWaiting; + + markToggled(); + onToggle(waits); + router.visit( + waits + ? PendingFollowController.store(profile) + : PendingFollowController.destroy(profile), + { + only: ['waitingCount', 'isWaiting', 'waitingYouFollow'], + preserveState: true, + preserveScroll: true, + async: true, + onStart: () => setIsPending(true), + onFinish: () => setIsPending(false), + onHttpException: (response) => { + onToggle(!waits); + + // Signed up meanwhile: the page is theirs now. + if (response.status === 404) { + router.reload(); + + return false; + } + + return !toastWhenThrottled(response); + }, + onNetworkError: () => onToggle(!waits), + }, + ); + }; + + return ( + + ); +} diff --git a/routes/web.php b/routes/web.php index 0474f2fa1..42cd4ddae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,6 +101,7 @@ use App\Http\Controllers\OrgReadController; use App\Http\Controllers\OrgTypingController; use App\Http\Controllers\PartnerController; +use App\Http\Controllers\PendingFollowController; use App\Http\Controllers\PrivacyController; use App\Http\Controllers\ProductMetricsController; use App\Http\Controllers\ProfileLookupController; @@ -675,6 +676,16 @@ ->middleware('throttle:follows') ->name('user-follow.destroy'); + // Waiting to follow a developer who isn't on /dev/null yet... + Route::post('profiles/{profile}/pending-follow', [PendingFollowController::class, 'store']) + ->whereUuid('profile') + ->middleware('throttle:follows') + ->name('pending-follow.store'); + Route::delete('profiles/{profile}/pending-follow', [PendingFollowController::class, 'destroy']) + ->whereUuid('profile') + ->middleware('throttle:follows') + ->name('pending-follow.destroy'); + // Organization Follow... Route::post('organizations/{organization}/follow', [OrganizationFollowController::class, 'store']) ->whereUuid('organization') diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 60160eaca..b3762ab68 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -90,7 +90,7 @@ $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data)->toBe([ - 'schema_version' => 5, + 'schema_version' => 6, 'exported_at' => now()->toIso8601String(), 'profile' => [ 'github_id' => $user->github_id, @@ -139,7 +139,7 @@ 'dismissed_suggestions' => [], 'organizations' => [], 'followed_organizations' => [], - 'pending_organization_follows' => [], + 'pending_follows' => [], 'messages' => [], 'reports' => [], 'remote_mutes' => [], @@ -515,9 +515,9 @@ ]); }); -it('exports the github organizations the user waits to follow', function (): void { +it('exports the GitHub accounts the user waits to follow', function (): void { $user = User::factory()->create(); - DB::table('pending_organization_follows')->insert([ + DB::table('pending_follows')->insert([ ['user_id' => $user->id, 'github_id' => '958072', 'created_at' => now()], ['user_id' => $user->id, 'github_id' => '1', 'created_at' => now()], ['user_id' => User::factory()->create()->id, 'github_id' => '2', 'created_at' => now()], @@ -526,7 +526,7 @@ $response = $this->actingAs($user)->get(route('export')); $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); - expect($data['pending_organization_follows'])->toBe(['1', '958072']); + expect($data['pending_follows'])->toBe(['1', '958072']); }); it('exports the devices approved for the app, oldest first', function (): void { diff --git a/tests/Feature/Controllers/PendingFollowControllerTest.php b/tests/Feature/Controllers/PendingFollowControllerTest.php new file mode 100644 index 000000000..27889bde8 --- /dev/null +++ b/tests/Feature/Controllers/PendingFollowControllerTest.php @@ -0,0 +1,43 @@ +create(); + $profile = Profile::factory()->create(); + + $this->actingAs($member)->from('/@'.$profile->handle)->post(route('pending-follow.store', $profile))->assertRedirect('/@'.$profile->handle); + $this->actingAs($member)->post(route('pending-follow.store', $profile))->assertRedirect(); + + expect(DB::table('pending_follows')->where('github_id', $profile->github_id)->pluck('user_id')->all())->toBe([$member->id]); + + $this->actingAs($member)->delete(route('pending-follow.destroy', $profile))->assertRedirect(); + + $this->assertDatabaseEmpty('pending_follows'); +}); + +it('waits only for developers who are not here yet', function (Closure $profile): void { + $member = User::factory()->create(); + $profile = $profile(); + + $this->actingAs($member)->post(route('pending-follow.store', $profile))->assertNotFound(); + $this->actingAs($member)->delete(route('pending-follow.destroy', $profile))->assertNotFound(); + + $this->assertDatabaseEmpty('pending_follows'); +})->with([ + 'a user' => fn (): Profile => User::factory()->create()->profile, + 'an organization' => fn (): Profile => Organization::factory()->create()->profile, +]); + +it('lets only members wait', function (): void { + $profile = Profile::factory()->create(); + + $this->post(route('pending-follow.store', $profile))->assertRedirect(route('home')); + + $this->assertDatabaseEmpty('pending_follows'); +}); diff --git a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php index f60ea3b1f..be3efd30e 100644 --- a/tests/Feature/Controllers/UnclaimedProfileControllerTest.php +++ b/tests/Feature/Controllers/UnclaimedProfileControllerTest.php @@ -4,6 +4,7 @@ use App\Models\Profile; use App\Models\User; +use Illuminate\Support\Facades\DB; it('shows an unclaimed developer to anyone with the link, but never where they are', function (): void { Profile::factory()->create([ @@ -20,6 +21,7 @@ ->assertInertia(fn ($page) => $page ->component('profile/unclaimed') ->where('profile', [ + 'id' => Profile::query()->where('handle', 'octocat')->sole()->id, 'handle' => 'octocat', 'name' => 'The Octocat', 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', @@ -56,3 +58,25 @@ ->assertOk() ->assertInertia(fn ($page) => $page->component('user/show')->where('user.id', $user->id)); }); + +it('shows how many members wait to follow them, and members the ones they follow among them', function (): void { + $profile = Profile::factory()->create(['handle' => 'octocat']); + $member = User::factory()->create(); + [$followed, $other] = User::factory()->count(2)->create(); + $stranger = User::factory()->create(); + $member->following()->attach([$followed->id, $other->id]); + DB::table('pending_follows')->insert(collect([$followed, $stranger, $member])->map(fn (User $user): array => ['user_id' => $user->id, 'github_id' => $profile->github_id, 'created_at' => now()])->all()); + + $this->get('/@octocat') + ->assertInertia(fn ($page) => $page + ->where('waitingCount', 3) + ->where('isWaiting', false) + ->where('waitingYouFollow', [])); + + $this->actingAs($member) + ->get('/@octocat') + ->assertInertia(fn ($page) => $page + ->where('waitingCount', 3) + ->where('isWaiting', true) + ->where('waitingYouFollow', [$followed->only(['id', 'handle', 'name', 'avatar_url'])])); +}); diff --git a/tests/Unit/Actions/CreatePendingFollowTest.php b/tests/Unit/Actions/CreatePendingFollowTest.php new file mode 100644 index 000000000..bc99a6f74 --- /dev/null +++ b/tests/Unit/Actions/CreatePendingFollowTest.php @@ -0,0 +1,32 @@ +handle(User::factory()->create(), $profile()); + + $this->assertDatabaseEmpty('pending_follows'); +})->with([ + 'signed up' => fn (): Profile => User::factory()->create()->profile, + 'gone' => function (): Profile { + $profile = Profile::factory()->create(); + $profile->delete(); + + return $profile; + }, +]); + +it('waits for a developer once, however many times asked', function (): void { + $user = User::factory()->create(); + $profile = Profile::factory()->create(); + + resolve(CreatePendingFollow::class)->handle($user, $profile); + resolve(CreatePendingFollow::class)->handle($user, $profile); + + expect(DB::table('pending_follows')->count())->toBe(1); +}); diff --git a/tests/Unit/Actions/CreateUserGitHubFollowsTest.php b/tests/Unit/Actions/CreateUserGitHubFollowsTest.php index e39ad7835..91da8f3c4 100644 --- a/tests/Unit/Actions/CreateUserGitHubFollowsTest.php +++ b/tests/Unit/Actions/CreateUserGitHubFollowsTest.php @@ -160,7 +160,7 @@ resolve(CreateUserGitHubFollows::class)->handle($user); expect($user->followedOrganizations()->pluck('organizations.id')->all())->toBe([$organization->id]) - ->and(DB::table('pending_organization_follows')->get(['user_id', 'github_id'])->map(fn (object $row): array => (array) $row)->all())->toBe([['user_id' => $user->id, 'github_id' => '958072']]) + ->and(DB::table('pending_follows')->get(['user_id', 'github_id'])->map(fn (object $row): array => (array) $row)->all())->toBe([['user_id' => $user->id, 'github_id' => '958072']]) ->and($user->refresh()->github_organization_follows_imported_at)->not->toBeNull() ->and(Notification::query()->count())->toBe(0); }); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index 16369e444..bb501f481 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -241,17 +241,17 @@ expect($organization->followers()->pluck('users.id')->all())->toBe([$other->id]); }); -it('erases the github organizations a deleted user waited to follow', function (): void { +it('erases the accounts a deleted user waited to follow', function (): void { $user = User::factory()->create(); $other = User::factory()->create(); - DB::table('pending_organization_follows')->insert([ + DB::table('pending_follows')->insert([ ['user_id' => $user->id, 'github_id' => '958072', 'created_at' => now()], ['user_id' => $other->id, 'github_id' => '958072', 'created_at' => now()], ]); resolve(DeleteUser::class)->handle($user); - expect(DB::table('pending_organization_follows')->pluck('user_id')->all())->toBe([$other->id]); + expect(DB::table('pending_follows')->pluck('user_id')->all())->toBe([$other->id]); }); it('erases the devices of a deleted user', function (): void { diff --git a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php index eab36df7c..242825fbe 100644 --- a/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php +++ b/tests/Unit/Actions/UpdateOrCreateOrganizationTest.php @@ -65,7 +65,7 @@ it('is followed, once created, by the users following it on GitHub whose follows were imported before', function (): void { [$follower, $other] = User::factory()->count(2)->create(); - DB::table('pending_organization_follows')->insert([ + DB::table('pending_follows')->insert([ ['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()], ['user_id' => $other->id, 'github_id' => '1', 'created_at' => now()], ]); @@ -73,5 +73,5 @@ $organization = resolve(UpdateOrCreateOrganization::class)->handle(['login' => 'laravel', 'id' => 958072]); expect($organization->followers()->pluck('users.id')->all())->toBe([$follower->id]) - ->and(DB::table('pending_organization_follows')->pluck('user_id')->all())->toBe([$other->id]); + ->and(DB::table('pending_follows')->pluck('user_id')->all())->toBe([$other->id]); }); diff --git a/tests/Unit/Actions/UpdateOrCreateUserTest.php b/tests/Unit/Actions/UpdateOrCreateUserTest.php index fbc615066..80e306d30 100644 --- a/tests/Unit/Actions/UpdateOrCreateUserTest.php +++ b/tests/Unit/Actions/UpdateOrCreateUserTest.php @@ -4,6 +4,8 @@ use App\Actions\UpdateOrCreateUser; use App\Contracts\Services\GitHubProvider; +use App\Enums\NotificationType; +use App\Models\Notification; use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -288,3 +290,24 @@ ->and(Profile::query()->count())->toBe(1); $this->assertDatabaseEmpty('deleted_github_accounts'); }); + +it('makes the members who waited for a new user follow them, each heard from once', function (): void { + $this->mock(GitHubProvider::class)->shouldReceive('hasSponsorsListing')->andReturnFalse(); + $unclaimed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octocat']); + [$first, $second] = User::factory()->count(2)->create(); + DB::table('pending_follows')->insert([ + ['user_id' => $first->id, 'github_id' => '583231', 'created_at' => now()], + ['user_id' => $second->id, 'github_id' => '583231', 'created_at' => now()], + ['user_id' => $first->id, 'github_id' => '958072', 'created_at' => now()], + ]); + + $user = resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake(['id' => 583231, 'nickname' => 'octocat'])); + + expect($user->followers()->pluck('users.id')->sort()->values()->all())->toBe(collect([$first->id, $second->id])->sort()->values()->all()) + ->and(Notification::query()->where('recipient_id', $user->id)->where('type', NotificationType::Followed)->count())->toBe(2) + ->and(DB::table('pending_follows')->pluck('github_id')->all())->toBe(['958072']); + + resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake(['id' => 583231, 'nickname' => 'octocat'])); + + expect(Notification::query()->where('recipient_id', $user->id)->count())->toBe(2); +}); diff --git a/tests/Unit/Jobs/LookUpGitHubProfileTest.php b/tests/Unit/Jobs/LookUpGitHubProfileTest.php index 8976df29f..ea39cc29c 100644 --- a/tests/Unit/Jobs/LookUpGitHubProfileTest.php +++ b/tests/Unit/Jobs/LookUpGitHubProfileTest.php @@ -34,6 +34,7 @@ function octocatAccount(array $attributes = []): array $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $github->shouldReceive('followerIds')->once()->with('octocat', 10)->andReturn([]); Cache::put('github-profiles:looking-up:octocat', true, 30); dispatch_sync(new LookUpGitHubProfile('octocat')); @@ -54,12 +55,26 @@ function octocatAccount(array $attributes = []): array ->and(Cache::has('github-profiles:looking-up:octocat'))->toBeFalse(); }); +it('makes the members following a developer on GitHub wait to follow them here', function (): void { + $follower = User::factory()->create(); + User::factory()->create(); + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('accountByLogin')->once()->andReturn(octocatAccount()); + $github->shouldReceive('followerIds')->once()->with('octocat', 10)->andReturn([$follower->github_id, '424242']); + + dispatch_sync(new LookUpGitHubProfile('octocat')); + + expect(DB::table('pending_follows')->get(['user_id', 'github_id'])->map(fn (object $row): array => (array) $row)->all()) + ->toBe([['user_id' => $follower->id, 'github_id' => '583231']]); +}); + it('gives a GitHub organization its page, followed by those waiting for it', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('laravel')->andReturn(['login' => 'laravel', 'id' => 958072, 'type' => 'Organization', 'name' => 'Laravel', 'avatar_url' => null, 'bio' => null, 'location' => null, 'blog' => '', 'company' => null]); $follower = User::factory()->create(); - DB::table('pending_organization_follows')->insert(['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()]); + DB::table('pending_follows')->insert(['user_id' => $follower->id, 'github_id' => '958072', 'created_at' => now()]); dispatch_sync(new LookUpGitHubProfile('laravel')); @@ -103,15 +118,19 @@ function octocatAccount(array $attributes = []): array $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $github->shouldNotReceive('followerIds'); $renamed = Profile::factory()->create(['github_id' => '583231', 'handle' => 'octo-old']); $previous = Profile::factory()->create(['github_id' => '1', 'handle' => 'octocat']); $member = User::factory()->create(['handle' => 'octocat-member']); + DB::table('pending_follows')->insert(['user_id' => $member->id, 'github_id' => '1', 'created_at' => now()]); dispatch_sync(new LookUpGitHubProfile('octocat')); expect($renamed->refresh()->handle)->toBe('octocat') ->and($member->profile->refresh()->handle)->toBe('octocat-member'); $this->assertModelMissing($previous); + + expect(DB::table('pending_follows')->pluck('github_id')->all())->toBe(['1']); }); it('only moves a user who renamed on GitHub to their new handle, leaving the rest to their next sign-in', function (): void { @@ -134,6 +153,7 @@ function octocatAccount(array $attributes = []): array $github = $this->mock(GitHubProvider::class); $github->shouldReceive('rateLimit')->andReturnNull(); $github->shouldReceive('accountByLogin')->once()->with('octocat')->andReturn(octocatAccount()); + $github->shouldReceive('followerIds')->once()->andReturn([]); $member = User::factory()->create(['github_id' => '1', 'handle' => 'octocat']); dispatch_sync(new LookUpGitHubProfile('octocat'));