From 56f1c091c7b2432c6a05fc05a9c27145abecc035 Mon Sep 17 00:00:00 2001 From: StoriaJames Date: Mon, 17 Aug 2026 17:16:32 +0800 Subject: [PATCH 01/29] fix: Facebook Page fetch missing New Pages Experience pages /me/accounts silently omits Pages that live under Meta's newer "New Pages Experience" / Business Portfolio model, even when the token's granular scopes show the Page was explicitly granted - confirmed via Meta's own Access Token Debugger against a live account whose Page returned zero results from /me/accounts but resolved fine when queried directly by ID. Falls back through Business Manager's owned_pages/client_pages (via the existing business_management scope) when /me/accounts comes back empty, so Pages under that model are still found. --- .../Controllers/Auth/FacebookController.php | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 0c9d9236c..4cb0fd916 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -32,6 +32,10 @@ class FacebookController extends SocialController 'pages_read_engagement', 'pages_manage_posts', 'read_insights', + // Needed for the Business Manager fallback in fetchPages() - /me/accounts + // omits Pages under the New Pages Experience model, and reaching those + // via /me/businesses + owned_pages requires this scope. + 'business_management', ]; public function connect(Request $request): Response @@ -272,6 +276,15 @@ private function fetchPages(string $userToken): array ], ); + // /me/accounts silently omits Pages that live under Meta's newer "New + // Pages Experience" / Business Portfolio model, even when the token's + // granular scopes show the Page was explicitly granted (confirmed via + // Meta's own Access Token Debugger, 2026-08-16). Falling back through + // Business Manager's owned_pages picks those up. + if (empty($pages)) { + $pages = $this->fetchPagesViaBusinessManager($userToken); + } + return collect($pages)->map(fn (array $page) => [ 'id' => data_get($page, 'id'), 'name' => data_get($page, 'name'), @@ -281,6 +294,52 @@ private function fetchPages(string $userToken): array ])->all(); } + private function fetchPagesViaBusinessManager(string $userToken): array + { + $businesses = GraphPaginator::all( + config('trypost.platforms.facebook.graph_api').'/me/businesses', + [ + 'access_token' => $userToken, + 'fields' => 'id', + 'limit' => 100, + ], + ); + + $pages = collect(); + + foreach ($businesses as $business) { + $businessId = data_get($business, 'id'); + + if (! $businessId) { + continue; + } + + foreach (['owned_pages', 'client_pages'] as $edge) { + try { + $edgePages = GraphPaginator::all( + config('trypost.platforms.facebook.graph_api')."/{$businessId}/{$edge}", + [ + 'access_token' => $userToken, + 'fields' => 'id,name,username,picture{url},access_token', + 'limit' => 100, + ], + ); + } catch (\Throwable $e) { + Log::warning("Facebook Business Manager {$edge} lookup failed", [ + 'business_id' => $businessId, + 'error' => $e->getMessage(), + ]); + + continue; + } + + $pages = $pages->concat($edgePages); + } + } + + return $pages->unique(fn (array $page) => data_get($page, 'id'))->values()->all(); + } + private function graphVersion(): string { return Uri::of(config('trypost.platforms.facebook.graph_api'))->path(); From 1354f5076bec1fdd18479adbe8bc3eead7e88e1f Mon Sep 17 00:00:00 2001 From: StoriaJames Date: Mon, 17 Aug 2026 17:16:32 +0800 Subject: [PATCH 02/29] fix: Instagram-via-Facebook has the same New Pages Experience gap Same root cause and fix as the FacebookController fetchPages() fallback - the Page/Instagram-linked-Page lookup goes through the same /me/accounts call and is subject to the same Meta-side gap. --- .../Auth/InstagramFacebookController.php | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 2ed48574c..72f7b72f4 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -235,13 +235,22 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, private function fetchPagesWithInstagram(string $userToken): array { $graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api'); + $fields = 'id,name,username,picture{url},access_token,instagram_business_account'; $pages = GraphPaginator::all("{$graphApi}/me/accounts", [ 'access_token' => $userToken, - 'fields' => 'id,name,username,picture{url},access_token,instagram_business_account', + 'fields' => $fields, 'limit' => 100, ]); + // /me/accounts silently omits Pages that live under Meta's newer "New + // Pages Experience" / Business Portfolio model (confirmed via Meta's own + // Access Token Debugger, 2026-08-16). Fall back through Business + // Manager's owned_pages/client_pages, same fix as FacebookController. + if (empty($pages)) { + $pages = $this->fetchPagesViaBusinessManager($userToken, $graphApi, $fields); + } + return collect($pages) ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) ->map(function (array $page) use ($graphApi) { @@ -275,6 +284,46 @@ private function fetchPagesWithInstagram(string $userToken): array ->all(); } + private function fetchPagesViaBusinessManager(string $userToken, string $graphApi, string $fields): array + { + $businesses = GraphPaginator::all("{$graphApi}/me/businesses", [ + 'access_token' => $userToken, + 'fields' => 'id', + 'limit' => 100, + ]); + + $pages = collect(); + + foreach ($businesses as $business) { + $businessId = data_get($business, 'id'); + + if (! $businessId) { + continue; + } + + foreach (['owned_pages', 'client_pages'] as $edge) { + try { + $edgePages = GraphPaginator::all("{$graphApi}/{$businessId}/{$edge}", [ + 'access_token' => $userToken, + 'fields' => $fields, + 'limit' => 100, + ]); + } catch (\Throwable $e) { + Log::warning("Instagram-via-Facebook Business Manager {$edge} lookup failed", [ + 'business_id' => $businessId, + 'error' => $e->getMessage(), + ]); + + continue; + } + + $pages = $pages->concat($edgePages); + } + } + + return $pages->unique(fn (array $page) => data_get($page, 'id'))->values()->all(); + } + private function graphVersion(): string { return Uri::of(config('trypost.platforms.instagram-facebook.graph_api'))->path(); From e04d856f29cd3378dfa8a802bd640fbd5a548682 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 16:36:58 -0300 Subject: [PATCH 03/29] refactor: one place finds every Page a Meta login can publish to Both controllers walked /me/accounts and, when it came back empty, the Business Portfolio edges behind it. ManagedPages now owns that walk for the Facebook and Instagram-via-Facebook flows alike. Three behaviour changes come with it: The portfolio edges are read on every connect, not only when /me/accounts is empty, and merged by Page id. Someone holding one Page by a classic role and the rest through a portfolio was auto-connected to that single Page and never offered the others. /me/businesses is read only once /me/permissions confirms the login granted business_management, and a failure anywhere along the portfolio walk leaves the /me/accounts list standing. It used to escape into the callback's catch, turning "no pages" into "could not connect" for every login without the scope. Pages the login cannot get an access_token for are dropped. Connecting one produces an account that cannot publish. --- .../Controllers/Auth/FacebookController.php | 71 +---------- .../Auth/InstagramFacebookController.php | 61 +--------- app/Services/Social/Meta/ManagedPages.php | 110 ++++++++++++++++++ 3 files changed, 121 insertions(+), 121 deletions(-) create mode 100644 app/Services/Social/Meta/ManagedPages.php diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index b166c0d12..ebfe62d62 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -9,7 +9,7 @@ use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; -use App\Services\Social\Meta\GraphPaginator; +use App\Services\Social\Meta\ManagedPages; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; @@ -33,9 +33,6 @@ class FacebookController extends SocialController 'pages_read_engagement', 'pages_manage_posts', 'read_insights', - // Needed for the Business Manager fallback in fetchPages() - /me/accounts - // omits Pages under the New Pages Experience model, and reaching those - // via /me/businesses + owned_pages requires this scope. 'business_management', ]; @@ -225,24 +222,12 @@ public function select(Request $request): InertiaResponse private function fetchPages(string $userToken): array { - $pages = GraphPaginator::all( - config('trypost.platforms.facebook.graph_api').'/me/accounts', - [ - 'access_token' => $userToken, - 'fields' => 'id,name,username,picture{url},access_token', - 'limit' => 100, - ], + $pages = ManagedPages::forUser( + (string) config('trypost.platforms.facebook.graph_api'), + $userToken, + 'id,name,username,picture{url},access_token', ); - // /me/accounts silently omits Pages that live under Meta's newer "New - // Pages Experience" / Business Portfolio model, even when the token's - // granular scopes show the Page was explicitly granted (confirmed via - // Meta's own Access Token Debugger, 2026-08-16). Falling back through - // Business Manager's owned_pages picks those up. - if (empty($pages)) { - $pages = $this->fetchPagesViaBusinessManager($userToken); - } - return collect($pages)->map(fn (array $page) => [ 'id' => data_get($page, 'id'), 'name' => data_get($page, 'name'), @@ -252,52 +237,6 @@ private function fetchPages(string $userToken): array ])->all(); } - private function fetchPagesViaBusinessManager(string $userToken): array - { - $businesses = GraphPaginator::all( - config('trypost.platforms.facebook.graph_api').'/me/businesses', - [ - 'access_token' => $userToken, - 'fields' => 'id', - 'limit' => 100, - ], - ); - - $pages = collect(); - - foreach ($businesses as $business) { - $businessId = data_get($business, 'id'); - - if (! $businessId) { - continue; - } - - foreach (['owned_pages', 'client_pages'] as $edge) { - try { - $edgePages = GraphPaginator::all( - config('trypost.platforms.facebook.graph_api')."/{$businessId}/{$edge}", - [ - 'access_token' => $userToken, - 'fields' => 'id,name,username,picture{url},access_token', - 'limit' => 100, - ], - ); - } catch (\Throwable $e) { - Log::warning("Facebook Business Manager {$edge} lookup failed", [ - 'business_id' => $businessId, - 'error' => $e->getMessage(), - ]); - - continue; - } - - $pages = $pages->concat($edgePages); - } - } - - return $pages->unique(fn (array $page) => data_get($page, 'id'))->values()->all(); - } - private function graphVersion(): string { return Uri::of(config('trypost.platforms.facebook.graph_api'))->path(); diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 6c969dfb4..5ebe8719f 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -10,7 +10,7 @@ use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; use App\Models\Workspace; -use App\Services\Social\Meta\GraphPaginator; +use App\Services\Social\Meta\ManagedPages; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -205,21 +205,12 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, private function fetchPagesWithInstagram(string $userToken): array { $graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api'); - $fields = 'id,name,username,picture{url},access_token,instagram_business_account'; - $pages = GraphPaginator::all("{$graphApi}/me/accounts", [ - 'access_token' => $userToken, - 'fields' => $fields, - 'limit' => 100, - ]); - - // /me/accounts silently omits Pages that live under Meta's newer "New - // Pages Experience" / Business Portfolio model (confirmed via Meta's own - // Access Token Debugger, 2026-08-16). Fall back through Business - // Manager's owned_pages/client_pages, same fix as FacebookController. - if (empty($pages)) { - $pages = $this->fetchPagesViaBusinessManager($userToken, $graphApi, $fields); - } + $pages = ManagedPages::forUser( + $graphApi, + $userToken, + 'id,name,username,picture{url},access_token,instagram_business_account', + ); return collect($pages) ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) @@ -254,46 +245,6 @@ private function fetchPagesWithInstagram(string $userToken): array ->all(); } - private function fetchPagesViaBusinessManager(string $userToken, string $graphApi, string $fields): array - { - $businesses = GraphPaginator::all("{$graphApi}/me/businesses", [ - 'access_token' => $userToken, - 'fields' => 'id', - 'limit' => 100, - ]); - - $pages = collect(); - - foreach ($businesses as $business) { - $businessId = data_get($business, 'id'); - - if (! $businessId) { - continue; - } - - foreach (['owned_pages', 'client_pages'] as $edge) { - try { - $edgePages = GraphPaginator::all("{$graphApi}/{$businessId}/{$edge}", [ - 'access_token' => $userToken, - 'fields' => $fields, - 'limit' => 100, - ]); - } catch (\Throwable $e) { - Log::warning("Instagram-via-Facebook Business Manager {$edge} lookup failed", [ - 'business_id' => $businessId, - 'error' => $e->getMessage(), - ]); - - continue; - } - - $pages = $pages->concat($edgePages); - } - } - - return $pages->unique(fn (array $page) => data_get($page, 'id'))->values()->all(); - } - private function graphVersion(): string { return Uri::of(config('trypost.platforms.instagram-facebook.graph_api'))->path(); diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php new file mode 100644 index 000000000..0ff4fcdc2 --- /dev/null +++ b/app/Services/Social/Meta/ManagedPages.php @@ -0,0 +1,110 @@ +> + * + * @throws IncompleteMetaGraphPaginationException + */ + public static function forUser(string $graphApi, string $userToken, string $fields): array + { + $pages = collect(GraphPaginator::all("{$graphApi}/me/accounts", [ + 'access_token' => $userToken, + 'fields' => $fields, + 'limit' => self::PER_PAGE, + ])); + + foreach (self::businessIds($graphApi, $userToken) as $businessId) { + foreach (['owned_pages', 'client_pages'] as $edge) { + $pages = $pages->concat(self::optional("{$graphApi}/{$businessId}/{$edge}", [ + 'access_token' => $userToken, + 'fields' => $fields, + 'limit' => self::PER_PAGE, + ])); + } + } + + return $pages + ->filter(fn (array $page) => filled(data_get($page, 'access_token'))) + ->unique(fn (array $page) => (string) data_get($page, 'id')) + ->values() + ->all(); + } + + /** + * @return list + */ + private static function businessIds(string $graphApi, string $userToken): array + { + if (! self::grantsBusinessManagement($graphApi, $userToken)) { + return []; + } + + return collect(self::optional("{$graphApi}/me/businesses", [ + 'access_token' => $userToken, + 'limit' => self::PER_PAGE, + ])) + ->pluck('id') + ->filter() + ->map(strval(...)) + ->values() + ->all(); + } + + private static function grantsBusinessManagement(string $graphApi, string $userToken): bool + { + try { + $response = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/me/permissions", [ + 'access_token' => $userToken, + ]); + } catch (ConnectionException) { + return false; + } + + if ($response->failed()) { + return false; + } + + return $response->collect('data')->contains( + fn ($permission) => data_get($permission, 'permission') === 'business_management' + && data_get($permission, 'status') === 'granted', + ); + } + + /** + * @param array $query + * @return list> + */ + private static function optional(string $url, array $query): array + { + try { + return GraphPaginator::all($url, $query); + } catch (IncompleteMetaGraphPaginationException) { + return []; + } + } +} From 182687d7d50c941f7457e32573c217131bee78bd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 16:37:02 -0300 Subject: [PATCH 04/29] test: pin the Page a login only reaches through a portfolio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the merge with /me/accounts, the access_token filter, the business_management gate, and a failing portfolio edge leaving the /me/accounts list intact — plus the connect flow end to end on both Meta platforms. The Instagram-via-Facebook request count moves from five to six for the /me/permissions check. --- .../Feature/Social/FacebookControllerTest.php | 80 +++++++++++ .../InstagramFacebookControllerTest.php | 60 +++++++- tests/Unit/Social/Meta/ManagedPagesTest.php | 132 ++++++++++++++++++ 3 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Social/Meta/ManagedPagesTest.php diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index a162a88b6..cb748faa2 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -882,3 +882,83 @@ ->where('message', __('accounts.popup_callback.all_connected')) ); }); + +test('facebook callback connects a page the user only administers through a business portfolio', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response([ + 'data' => [['permission' => 'business_management', 'status' => 'granted']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [ + [ + 'id' => 'page_owned_by_client', + 'name' => "Client's Page", + 'username' => 'clientpage', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'portfolio-page-token', + ], + ], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Facebook->value, + 'platform_user_id' => 'page_owned_by_client', + 'display_name' => "Client's Page", + 'status' => Status::Connected->value, + ]); +}); + +test('facebook callback still reports no pages when the portfolio has none either', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response([ + 'data' => [['permission' => 'business_management', 'status' => 'granted']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.no_facebook_pages'))); +}); diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index 5e95070eb..9d6fafacd 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Platform; +use App\Enums\SocialAccount\Status; use App\Enums\UserWorkspace\Role; use App\Models\SocialAccount; use App\Models\User; @@ -87,7 +88,7 @@ ->and(data_get(session('instagram_facebook_oauth.pages'), '0.ig_id'))->toBe('ig_1') ->and(data_get(session('instagram_facebook_oauth.pages'), '1.ig_id'))->toBe('ig_2'); - Http::assertSentCount(5); // /me + 2 accounts pages + 2 IG lookups + Http::assertSentCount(6); // /me + 2 accounts pages + /me/permissions + 2 IG lookups }); test('instagram-facebook callback connects page when first accounts response is empty', function () { @@ -516,3 +517,60 @@ ->exists())->toBeFalse() ->and($this->workspace->socialAccounts()->count())->toBe(1); }); + +test('instagram via facebook connects a page reached through a business portfolio', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response([ + 'data' => [['permission' => 'business_management', 'status' => 'granted']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [ + [ + 'id' => 'page_portfolio', + 'name' => 'Portfolio Page', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'portfolio-page-token', + 'instagram_business_account' => ['id' => 'ig_portfolio'], + ], + ], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + "{$graphApi}/ig_portfolio*" => Http::response([ + 'username' => 'portfolio_ig', + 'name' => 'Portfolio IG', + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback')); + + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::InstagramFacebook->value, + 'platform_user_id' => 'ig_portfolio', + 'username' => 'portfolio_ig', + 'status' => Status::Connected->value, + ]); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php new file mode 100644 index 000000000..6f27b6205 --- /dev/null +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -0,0 +1,132 @@ + array_filter([ + ['permission' => 'pages_show_list', 'status' => 'granted'], + $businessManagement ? ['permission' => 'business_management', 'status' => 'granted'] : null, + ])]; +} + +test('business portfolio pages are found when me/accounts is empty', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Owned Page', 'access_token' => 'owned-token']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response([ + 'data' => [['id' => 'page_2', 'name' => 'Client Page', 'access_token' => 'client-token']], + ], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(2) + ->and(data_get($pages, '0.id'))->toBe('page_1') + ->and(data_get($pages, '1.id'))->toBe('page_2'); +}); + +test('a page listed in both me/accounts and a portfolio is returned once, keeping its user token', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'portfolio-token']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1) + ->and(data_get($pages, '0.access_token'))->toBe('role-token'); +}); + +test('portfolio pages the login cannot get a token for are dropped', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [ + ['id' => 'page_1', 'name' => 'No Access'], + ['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'], + ], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1) + ->and(data_get($pages, '0.id'))->toBe('page_2'); +}); + +test('portfolio edges are left alone when business_management was not granted', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(false), 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); +}); + +test('a failing portfolio edge keeps the pages me/accounts already returned', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'nope']], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['error' => ['message' => 'nope']], 400), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1) + ->and(data_get($pages, '0.id'))->toBe('page_1'); +}); + +test('a failing me/accounts still aborts instead of reporting no pages', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); +})->throws(IncompleteMetaGraphPaginationException::class); From 71a423d59ab8b12dc367e9e63ab5c1cb0228f6ca Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 16:44:11 -0300 Subject: [PATCH 05/29] refactor: read the portfolio edges without asking permission first The /me/permissions check saved a rejected /me/businesses call on logins without business_management, at the cost of running a path nobody had verified against a live account. The edges already fail soft, so the check bought log tidiness and nothing else. --- app/Services/Social/Meta/ManagedPages.php | 32 ++----------------- .../Feature/Social/FacebookControllerTest.php | 6 ---- .../InstagramFacebookControllerTest.php | 5 +-- tests/Unit/Social/Meta/ManagedPagesTest.php | 22 ++++--------- 4 files changed, 10 insertions(+), 55 deletions(-) diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 0ff4fcdc2..47df94d42 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -5,8 +5,6 @@ namespace App\Services\Social\Meta; use App\Exceptions\Social\IncompleteMetaGraphPaginationException; -use Illuminate\Http\Client\ConnectionException; -use Illuminate\Support\Facades\Http; /** * Every Facebook Page a login can publish to, gathered from all the edges Meta lists them under. @@ -17,9 +15,9 @@ * list there, so the portfolio's own `owned_pages` and `client_pages` edges are read * too and merged by Page id. * - * Those edges need `business_management`, which a login may not grant, so they are - * read only once the permission is confirmed and never turn a usable `/me/accounts` - * list into a failure. + * Those edges need `business_management`, which a login may not grant, so every one + * of them is best-effort: a rejection there never turns a usable `/me/accounts` list + * into a failed connect. */ class ManagedPages { @@ -60,10 +58,6 @@ public static function forUser(string $graphApi, string $userToken, string $fiel */ private static function businessIds(string $graphApi, string $userToken): array { - if (! self::grantsBusinessManagement($graphApi, $userToken)) { - return []; - } - return collect(self::optional("{$graphApi}/me/businesses", [ 'access_token' => $userToken, 'limit' => self::PER_PAGE, @@ -75,26 +69,6 @@ private static function businessIds(string $graphApi, string $userToken): array ->all(); } - private static function grantsBusinessManagement(string $graphApi, string $userToken): bool - { - try { - $response = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/me/permissions", [ - 'access_token' => $userToken, - ]); - } catch (ConnectionException) { - return false; - } - - if ($response->failed()) { - return false; - } - - return $response->collect('data')->contains( - fn ($permission) => data_get($permission, 'permission') === 'business_management' - && data_get($permission, 'status') === 'granted', - ); - } - /** * @param array $query * @return list> diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index cb748faa2..d93686ad3 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -901,9 +901,6 @@ Http::fake([ "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/permissions*" => Http::response([ - 'data' => [['permission' => 'business_management', 'status' => 'granted']], - ], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ 'data' => [ @@ -950,9 +947,6 @@ Http::fake([ "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/permissions*" => Http::response([ - 'data' => [['permission' => 'business_management', 'status' => 'granted']], - ], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), ]); diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index 9d6fafacd..f7646bfe8 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -88,7 +88,7 @@ ->and(data_get(session('instagram_facebook_oauth.pages'), '0.ig_id'))->toBe('ig_1') ->and(data_get(session('instagram_facebook_oauth.pages'), '1.ig_id'))->toBe('ig_2'); - Http::assertSentCount(6); // /me + 2 accounts pages + /me/permissions + 2 IG lookups + Http::assertSentCount(6); // /me + 2 accounts pages + /me/businesses + 2 IG lookups }); test('instagram-facebook callback connects page when first accounts response is empty', function () { @@ -540,9 +540,6 @@ Http::fake([ "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/permissions*" => Http::response([ - 'data' => [['permission' => 'business_management', 'status' => 'granted']], - ], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ 'data' => [ diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 6f27b6205..32f3e2105 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -13,20 +13,11 @@ function managedPagesGraphApi(): string return (string) config('trypost.platforms.facebook.graph_api'); } -function managedPagesPermissions(bool $businessManagement): array -{ - return ['data' => array_filter([ - ['permission' => 'pages_show_list', 'status' => 'granted'], - $businessManagement ? ['permission' => 'business_management', 'status' => 'granted'] : null, - ])]; -} - test('business portfolio pages are found when me/accounts is empty', function () { $graphApi = managedPagesGraphApi(); Http::fake([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Owned Page', 'access_token' => 'owned-token']], @@ -50,7 +41,6 @@ function managedPagesPermissions(bool $businessManagement): array "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), - "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'portfolio-token']], @@ -69,7 +59,6 @@ function managedPagesPermissions(bool $businessManagement): array Http::fake([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ 'data' => [ @@ -86,20 +75,22 @@ function managedPagesPermissions(bool $businessManagement): array ->and(data_get($pages, '0.id'))->toBe('page_2'); }); -test('portfolio edges are left alone when business_management was not granted', function () { +test('a login without business_management keeps the pages me/accounts returned', function () { $graphApi = managedPagesGraphApi(); Http::fake([ "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), - "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(false), 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'error' => ['message' => 'Requires business_management permission'], + ], 403), ]); $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - expect($pages)->toHaveCount(1); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); + expect($pages)->toHaveCount(1) + ->and(data_get($pages, '0.id'))->toBe('page_1'); }); test('a failing portfolio edge keeps the pages me/accounts already returned', function () { @@ -109,7 +100,6 @@ function managedPagesPermissions(bool $businessManagement): array "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), - "{$graphApi}/me/permissions*" => Http::response(managedPagesPermissions(true), 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'nope']], 400), "{$graphApi}/biz_1/client_pages*" => Http::response(['error' => ['message' => 'nope']], 400), From 4cf4b154f763149c76902060b79ee6f878396a39 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 16:49:20 -0300 Subject: [PATCH 06/29] test: cover the portfolio walk's remaining shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-Page selection flow behind a portfolio — the case #292 asked a maintainer to check — plus pages spread across two portfolios, a paginated edge, a portfolio entry with no id, and a portfolio page merging with one /me/accounts already returned. --- .../Feature/Social/FacebookControllerTest.php | 118 ++++++++++++++++++ tests/Unit/Social/Meta/ManagedPagesTest.php | 61 +++++++++ 2 files changed, 179 insertions(+) diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index d93686ad3..ababe0db3 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -956,3 +956,121 @@ ->where('success', false) ->where('message', __('accounts.popup_callback.no_facebook_pages'))); }); + +test('facebook callback offers every portfolio page when the portfolio holds more than one', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [ + [ + 'id' => 'page_owned', + 'name' => 'Owned Page', + 'username' => 'owned', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'owned-token', + ], + ], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response([ + 'data' => [ + [ + 'id' => 'page_client', + 'name' => 'Client Page', + 'username' => 'client', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'client-token', + ], + ], + ], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + $response->assertRedirect(route('app.social.facebook.select-page')); + expect(session('facebook_oauth.pages'))->toHaveCount(2) + ->and(data_get(session('facebook_oauth.pages'), '0.id'))->toBe('page_owned') + ->and(data_get(session('facebook_oauth.pages'), '1.id'))->toBe('page_client'); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.select-page')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/FacebookPageSelect') + ->has('pages', 2)); + + $this->actingAs($this->user) + ->post(route('app.social.facebook.select'), ['page_id' => 'page_client']) + ->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $account = SocialAccount::where('platform_user_id', 'page_client')->sole(); + + expect($account->workspace_id)->toBe($this->workspace->id) + ->and($account->platform)->toBe(Platform::Facebook) + ->and($account->display_name)->toBe('Client Page') + ->and($account->access_token)->toBe('client-token'); +}); + +test('facebook callback merges a portfolio page with the one me/accounts already returned', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [ + [ + 'id' => 'page_role', + 'name' => 'Role Page', + 'username' => 'role', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'role-token', + ], + ], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [ + [ + 'id' => 'page_portfolio', + 'name' => 'Portfolio Page', + 'username' => 'portfolio', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'portfolio-token', + ], + ], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + $response->assertRedirect(route('app.social.facebook.select-page')); + expect(collect(session('facebook_oauth.pages'))->pluck('id')->all()) + ->toBe(['page_role', 'page_portfolio']); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 32f3e2105..8ca350e53 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -120,3 +120,64 @@ function managedPagesGraphApi(): string ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); })->throws(IncompleteMetaGraphPaginationException::class); + +test('pages spread across several portfolios are all collected', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'data' => [['id' => 'biz_1'], ['id' => 'biz_2']], + ], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + "{$graphApi}/biz_2/owned_pages*" => Http::response(['data' => []], 200), + "{$graphApi}/biz_2/client_pages*" => Http::response([ + 'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']], + ], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect(collect($pages)->pluck('id')->all())->toBe(['page_1', 'page_2']); +}); + +test('a paginated portfolio edge is followed to the end', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::sequence() + ->push([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor1"], + ], 200) + ->push([ + 'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect(collect($pages)->pluck('id')->all())->toBe(['page_1', 'page_2']); +}); + +test('a portfolio entry without an id is skipped', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['name' => 'No Id']]], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'owned_pages')); +}); From a8f4161bc5cc98d031a7fbc0b825146d36e707d0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:01:32 -0300 Subject: [PATCH 07/29] test: stop the Meta connect tests from calling Graph for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Http::fake only stubs the URLs it is given; anything else goes out over the network. These files stubbed /me/accounts and left /me — and now /me/businesses — unstubbed, so the suite was issuing live requests to graph.facebook.com on every run. They came back 400 and the code under test swallowed them, so nothing ever went red while the assertions were measuring Meta's answer instead of the fixture's. Every Graph call the connect flow makes is stubbed now, and the files prevent stray requests so a missing one fails loudly. Inertia's SSR endpoint is allowed through; it is not what these tests are about. --- .../Feature/Social/FacebookControllerTest.php | 23 +++++++++++++++++++ .../InstagramFacebookControllerTest.php | 10 ++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index ababe0db3..ba0a230d4 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -15,6 +15,9 @@ use Laravel\Socialite\Two\User as SocialiteUser; beforeEach(function () { + Http::preventStrayRequests(); + Http::allowStrayRequests(['*__inertia_ssr*']); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); $this->user->update(['current_workspace_id' => $this->workspace->id]); @@ -56,6 +59,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ @@ -105,6 +110,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ @@ -143,6 +150,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ @@ -183,6 +192,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [], ], 200), @@ -211,6 +222,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400), ]); @@ -243,6 +255,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -299,6 +312,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -350,6 +364,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -409,6 +424,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ @@ -703,6 +720,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ @@ -762,6 +781,8 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ @@ -866,6 +887,8 @@ ->andReturn($driverMock); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ ['id' => 'page-1', 'name' => 'Only Page', 'access_token' => 'page-token'], diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index f7646bfe8..f7a3c6695 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -14,6 +14,9 @@ use Laravel\Socialite\Two\User as SocialiteUser; beforeEach(function () { + Http::preventStrayRequests(); + Http::allowStrayRequests(['*__inertia_ssr*']); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); $this->user->update(['current_workspace_id' => $this->workspace->id]); @@ -42,6 +45,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -113,6 +117,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -173,6 +178,7 @@ $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ @@ -224,6 +230,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -292,6 +299,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -490,6 +498,8 @@ ->andReturn(Mockery::mock(['user' => $socialiteUser])); Http::fake([ + 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ 'data' => [ [ From 845f1f13c73262abd42def4ee17b45a80419be93 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:01:32 -0300 Subject: [PATCH 08/29] fix: tell a denied portfolio edge apart from a throttled one GraphPaginator throws so no caller reads a failed fetch as an empty list and auto-connects whatever arrived first. Swallowing that exception on the portfolio edges gave the invariant away: a 429 on owned_pages left the merged list holding only the /me/accounts page, and the callback connected it with no picker. The exception now carries whether the failure was transient, classified by GraphError, which already owns Meta's rate-limit and transient code table. A denied permission reads as "this login reaches no portfolio pages"; a throttle, a 5xx or a truncated walk is raised. The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each portfolio costs two more paginated edges inside a synchronous OAuth callback, and nothing bounded that loop. --- ...IncompleteMetaGraphPaginationException.php | 8 +- app/Services/Social/Meta/GraphPaginator.php | 5 +- app/Services/Social/Meta/ManagedPages.php | 43 +++++++++-- tests/Unit/Social/Meta/ManagedPagesTest.php | 76 +++++++++++++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) diff --git a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php index 4d4440ea8..550a1db80 100644 --- a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php +++ b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php @@ -12,10 +12,16 @@ * failed, a later page failed, or pagination stopped pathologically. Callers * must not treat this as an empty or complete list (e.g. "no pages" or * auto-connect when count === 1). + * + * `$transient` separates a throttle, an upstream hiccup or a truncated walk — + * where the real list is unknown — from a confirmed rejection such as a denied + * permission, where Meta has told us this login reaches nothing on that edge. + * Only the latter is safe for a caller to read as an empty list; anything + * unknown defaults to transient. */ class IncompleteMetaGraphPaginationException extends RuntimeException { - public function __construct(?Throwable $previous = null) + public function __construct(?Throwable $previous = null, public readonly bool $transient = true) { parent::__construct('Meta Graph pagination did not complete.', previous: $previous); } diff --git a/app/Services/Social/Meta/GraphPaginator.php b/app/Services/Social/Meta/GraphPaginator.php index b51de03ee..0ccc12f51 100644 --- a/app/Services/Social/Meta/GraphPaginator.php +++ b/app/Services/Social/Meta/GraphPaginator.php @@ -101,6 +101,9 @@ private static function abort( 'fetched' => $fetched > 0 ? $fetched : null, ])); - throw new IncompleteMetaGraphPaginationException($e); + throw new IncompleteMetaGraphPaginationException( + $e, + transient: $response === null || GraphError::isTransientFailure($response), + ); } } diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 47df94d42..1a130b664 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -5,6 +5,7 @@ namespace App\Services\Social\Meta; use App\Exceptions\Social\IncompleteMetaGraphPaginationException; +use Illuminate\Support\Facades\Log; /** * Every Facebook Page a login can publish to, gathered from all the edges Meta lists them under. @@ -15,14 +16,23 @@ * list there, so the portfolio's own `owned_pages` and `client_pages` edges are read * too and merged by Page id. * - * Those edges need `business_management`, which a login may not grant, so every one - * of them is best-effort: a rejection there never turns a usable `/me/accounts` list - * into a failed connect. + * Those edges need `business_management`, which a login may not grant, so a rejection + * there reads as "this login reaches no portfolio pages" rather than failing the + * connect. A throttle or an upstream hiccup is not a rejection — it leaves the real + * list unknown, and is raised so no caller auto-connects a half-fetched list. */ class ManagedPages { private const PER_PAGE = 100; + /** + * Hard ceiling on portfolios walked. Each one costs two more paginated + * edges inside a synchronous OAuth callback, so this plays the same role + * for the portfolio loop that GraphPaginator::MAX_PAGES plays for a single + * edge: far above any real membership, there only to bound a runaway. + */ + public const MAX_PORTFOLIOS = 25; + /** * @return list> * @@ -58,26 +68,45 @@ public static function forUser(string $graphApi, string $userToken, string $fiel */ private static function businessIds(string $graphApi, string $userToken): array { - return collect(self::optional("{$graphApi}/me/businesses", [ + $ids = collect(self::optional("{$graphApi}/me/businesses", [ 'access_token' => $userToken, 'limit' => self::PER_PAGE, ])) ->pluck('id') ->filter() ->map(strval(...)) - ->values() - ->all(); + ->values(); + + if ($ids->count() > self::MAX_PORTFOLIOS) { + Log::warning('Meta portfolio walk truncated', [ + 'found' => $ids->count(), + 'walked' => self::MAX_PORTFOLIOS, + ]); + } + + return $ids->take(self::MAX_PORTFOLIOS)->all(); } /** + * An edge this login is simply not allowed to read answers with an empty + * list. A throttle, an upstream hiccup or a truncated walk leaves the real + * list unknown, and is raised so the caller never auto-connects whatever + * happened to arrive first. + * * @param array $query * @return list> + * + * @throws IncompleteMetaGraphPaginationException */ private static function optional(string $url, array $query): array { try { return GraphPaginator::all($url, $query); - } catch (IncompleteMetaGraphPaginationException) { + } catch (IncompleteMetaGraphPaginationException $e) { + if ($e->transient) { + throw $e; + } + return []; } } diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 8ca350e53..2279df94f 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -5,9 +5,14 @@ use App\Exceptions\Social\IncompleteMetaGraphPaginationException; use App\Services\Social\Meta\ManagedPages; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; const MANAGED_PAGES_FIELDS = 'id,name,access_token'; +beforeEach(function () { + Http::preventStrayRequests(); +}); + function managedPagesGraphApi(): string { return (string) config('trypost.platforms.facebook.graph_api'); @@ -181,3 +186,74 @@ function managedPagesGraphApi(): string expect($pages)->toHaveCount(1); Http::assertNotSent(fn ($request) => str_contains($request->url(), 'owned_pages')); }); + +test('a throttled portfolio edge is raised rather than read as no pages', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'code' => 4], + ], 400), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); +})->throws(IncompleteMetaGraphPaginationException::class); + +test('an upstream failure listing portfolios is raised rather than read as no portfolios', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['error' => ['message' => 'oops']], 500), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); +})->throws(IncompleteMetaGraphPaginationException::class); + +test('a portfolio edge denied by permissions reads as no pages', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'error' => ['message' => 'Requires business_management permission', 'code' => 200], + ], 403), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1) + ->and(data_get($pages, '0.id'))->toBe('page_1'); +}); + +test('the portfolio walk stops at the ceiling and says so', function () { + $graphApi = managedPagesGraphApi(); + $portfolios = collect(range(1, ManagedPages::MAX_PORTFOLIOS + 5)) + ->map(fn (int $n) => ['id' => "biz_{$n}"]) + ->all(); + + Log::shouldReceive('warning') + ->once() + ->with('Meta portfolio walk truncated', [ + 'found' => ManagedPages::MAX_PORTFOLIOS + 5, + 'walked' => ManagedPages::MAX_PORTFOLIOS, + ]); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), + "{$graphApi}/*_pages*" => Http::response(['data' => []], 200), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + Http::assertSentCount(1 + 1 + (ManagedPages::MAX_PORTFOLIOS * 2)); +}); From 7a8d68067e3068a8e38348adc67bbc3b94cd0a43 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:11:30 -0300 Subject: [PATCH 09/29] fix: three ways the portfolio walk misread what Meta returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent Instagram lookups. The Instagram description ran one request per Page, in sequence, at a 15s timeout each. That list used to be the Pages someone holds a role on — a handful. It is now the union with every portfolio's owned_pages and client_pages, so a portfolio holding hundreds of Pages serialised the OAuth callback past any gateway timeout, for exactly the accounts the portfolio walk exists to reach. Meta's ids= batching is no help: each Page carries its own access token and one call takes one token. The lookups run in concurrent rounds. A Page without a token is not a Page you don't have. Meta lets someone decline pages_read_engagement on its per-permission toggles and still lists the Page, without an access_token. Dropping it inside the walk left the caller saying "no Pages found, you need to be an admin of at least one" to an admin. ManagedPages returns everything Meta listed and publishable() separates what can be posted to, so the callbacks can tell the two apart and say which one happened. Stored scopes are what Meta granted. The scope list was written to the account's scopes column straight from the request, claiming access the login may have refused — business_management above all, which needs Advanced Access and is declined by default without it. It now comes from /me/permissions, falling back to the request when Meta cannot be asked. --- .../Controllers/Auth/FacebookController.php | 22 ++- .../Auth/InstagramFacebookController.php | 140 +++++++++++++----- .../Social/Meta/GrantedPermissions.php | 51 +++++++ app/Services/Social/Meta/ManagedPages.php | 18 ++- lang/ar/accounts.php | 1 + lang/de/accounts.php | 1 + lang/el/accounts.php | 1 + lang/en/accounts.php | 1 + lang/es/accounts.php | 1 + lang/fr/accounts.php | 1 + lang/it/accounts.php | 1 + lang/ja/accounts.php | 1 + lang/ko/accounts.php | 1 + lang/nl/accounts.php | 1 + lang/pl/accounts.php | 1 + lang/pt-BR/accounts.php | 1 + lang/ru/accounts.php | 1 + lang/tr/accounts.php | 1 + lang/uk/accounts.php | 1 + lang/zh/accounts.php | 1 + .../Feature/Social/FacebookControllerTest.php | 126 ++++++++++++++++ .../InstagramFacebookControllerTest.php | 89 ++++++++++- tests/Unit/Social/Meta/ManagedPagesTest.php | 15 +- 23 files changed, 426 insertions(+), 51 deletions(-) create mode 100644 app/Services/Social/Meta/GrantedPermissions.php diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index ebfe62d62..8111f349e 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -9,6 +9,7 @@ use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; +use App\Services\Social\Meta\GrantedPermissions; use App\Services\Social\Meta\ManagedPages; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -71,10 +72,15 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'access_token' => $socialUser->token, ]); - $pages = $this->fetchPages($socialUser->token); + $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); + + $listed = $this->fetchPages($socialUser->token); + $pages = ManagedPages::publishable($listed); if (empty($pages)) { - return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_pages'), $this->platform->value); + return $this->popupCallback(false, __(empty($listed) + ? 'accounts.popup_callback.no_facebook_pages' + : 'accounts.popup_callback.pages_missing_permission'), $this->platform->value); } $pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect); @@ -99,7 +105,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'access_token' => data_get($page, 'access_token'), 'refresh_token' => null, 'token_expires_at' => null, - 'scopes' => $this->scopes, + 'scopes' => $granted, 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, @@ -120,6 +126,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'facebook_oauth' => [ 'user_token' => $socialUser->token, 'user_id' => $socialUser->getId(), + 'scopes' => $granted, 'pages' => $pages, 'reconnect_id' => $reconnect?->id, ], @@ -193,7 +200,7 @@ public function select(Request $request): InertiaResponse 'access_token' => data_get($selectedPage, 'access_token'), 'refresh_token' => null, 'token_expires_at' => null, - 'scopes' => $this->scopes, + 'scopes' => data_get($oauthData, 'scopes', $this->scopes), 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, @@ -223,7 +230,7 @@ public function select(Request $request): InertiaResponse private function fetchPages(string $userToken): array { $pages = ManagedPages::forUser( - (string) config('trypost.platforms.facebook.graph_api'), + $this->graphApi(), $userToken, 'id,name,username,picture{url},access_token', ); @@ -237,6 +244,11 @@ private function fetchPages(string $userToken): array ])->all(); } + private function graphApi(): string + { + return (string) config('trypost.platforms.facebook.graph_api'); + } + private function graphVersion(): string { return Uri::of(config('trypost.platforms.facebook.graph_api'))->path(); diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 5ebe8719f..8d6ea90b2 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -10,11 +10,14 @@ use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; use App\Models\Workspace; +use App\Services\Social\Meta\GrantedPermissions; use App\Services\Social\Meta\ManagedPages; -use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Pool; +use Illuminate\Http\Client\Response as ClientResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Uri; @@ -29,6 +32,14 @@ class InstagramFacebookController extends SocialController protected SocialPlatform $platform = SocialPlatform::InstagramFacebook; + /** + * Instagram accounts described per pool round. Each Page carries its own + * access token, so the lookups cannot be batched into one `ids=` call — + * they run concurrently instead, in rounds, so a portfolio holding + * hundreds of Pages does not serialise the OAuth callback. + */ + private const INSTAGRAM_LOOKUPS_PER_ROUND = 20; + protected array $scopes = [ 'public_profile', 'pages_show_list', @@ -77,12 +88,19 @@ public function callback(Request $request): InertiaResponse|RedirectResponse 'access_token' => $socialUser->token, ]); - $pages = $this->fetchPagesWithInstagram($socialUser->token); + $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); - if (empty($pages)) { - return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_instagram_pages'), $this->platform->value); + $listed = $this->fetchPagesWithInstagram($socialUser->token); + $publishable = ManagedPages::publishable($listed); + + if (empty($publishable)) { + return $this->popupCallback(false, __(empty($listed) + ? 'accounts.popup_callback.no_facebook_instagram_pages' + : 'accounts.popup_callback.pages_missing_permission'), $this->platform->value); } + $pages = $this->describeInstagramAccounts($publishable); + $pages = $this->filterConnectableIdentities($workspace, $pages, 'ig_id', $existingAccount); if (empty($pages)) { @@ -90,13 +108,14 @@ public function callback(Request $request): InertiaResponse|RedirectResponse } if (count($pages) === 1) { - return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount); + return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount, $granted); } // Multiple pages — show selection session([ 'instagram_facebook_oauth' => [ 'user_token' => $socialUser->token, + 'scopes' => $granted, 'pages' => $pages, 'reconnect_id' => $existingAccount?->id, ], @@ -158,7 +177,12 @@ public function select(Request $request): InertiaResponse return $this->popupCallback(false, __('accounts.popup_callback.page_not_found'), $this->platform->value); } - $result = $this->connectInstagramAccount($workspace, $selectedPage, $existingAccount); + $result = $this->connectInstagramAccount( + $workspace, + $selectedPage, + $existingAccount, + data_get($oauthData, 'scopes', $this->scopes), + ); session()->forget('instagram_facebook_oauth'); @@ -172,7 +196,11 @@ public function select(Request $request): InertiaResponse } } - private function connectInstagramAccount(Workspace $workspace, array $pageData, ?SocialAccount $existingAccount): InertiaResponse + /** + * @param array $pageData + * @param array $scopes + */ + private function connectInstagramAccount(Workspace $workspace, array $pageData, ?SocialAccount $existingAccount, array $scopes): InertiaResponse { $avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null; @@ -187,7 +215,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, 'access_token' => data_get($pageData, 'page_access_token'), 'refresh_token' => null, 'token_expires_at' => null, - 'scopes' => $this->scopes, + 'scopes' => $scopes, 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, @@ -202,49 +230,79 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, return $this->connectedCallback($existingAccount); } + /** + * The Pages this login lists that have an Instagram business account linked, + * in Meta's own shape — `access_token` still on each, so the caller can tell + * a Page it cannot post to from one it never had. + * + * @return list> + */ private function fetchPagesWithInstagram(string $userToken): array { - $graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api'); - - $pages = ManagedPages::forUser( - $graphApi, + return collect(ManagedPages::forUser( + $this->graphApi(), $userToken, 'id,name,username,picture{url},access_token,instagram_business_account', - ); + )) + ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) + ->values() + ->all(); + } + /** + * @param array> $pages + * @return list> + */ + private function describeInstagramAccounts(array $pages): array + { return collect($pages) - ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) - ->map(function (array $page) use ($graphApi) { - $igId = data_get($page, 'instagram_business_account.id'); - $token = data_get($page, 'access_token'); - $igData = []; - - try { - $ig = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/{$igId}", [ - 'access_token' => $token, - 'fields' => 'username,name,profile_picture_url', - ]); - - $igData = $ig->successful() ? $ig->json() : []; - } catch (ConnectionException) { - // Page listing still succeeds; username/avatar may be empty. - } - - return [ - 'page_id' => data_get($page, 'id'), - 'page_name' => data_get($page, 'name'), - 'page_picture' => data_get($page, 'picture.data.url'), - 'page_access_token' => $token, - 'ig_id' => $igId, - 'ig_username' => data_get($igData, 'username'), - 'ig_name' => data_get($igData, 'name'), - 'ig_picture' => data_get($igData, 'profile_picture_url'), - ]; - }) + ->chunk(self::INSTAGRAM_LOOKUPS_PER_ROUND) + ->flatMap($this->describeRound(...)) ->values() ->all(); } + /** + * @param Collection> $pages + * @return Collection> + */ + private function describeRound(Collection $pages): Collection + { + $pages = $pages->values(); + $graphApi = $this->graphApi(); + + $responses = Http::pool(fn (Pool $pool) => $pages + ->map(fn (array $page) => $pool + ->timeout(15) + ->connectTimeout(5) + ->get("{$graphApi}/".data_get($page, 'instagram_business_account.id'), [ + 'access_token' => data_get($page, 'access_token'), + 'fields' => 'username,name,profile_picture_url', + ])) + ->all()); + + return $pages->map(function (array $page, int $index) use ($responses) { + $response = data_get($responses, $index); + $igData = $response instanceof ClientResponse && $response->successful() ? $response->json() : []; + + return [ + 'page_id' => data_get($page, 'id'), + 'page_name' => data_get($page, 'name'), + 'page_picture' => data_get($page, 'picture.data.url'), + 'page_access_token' => data_get($page, 'access_token'), + 'ig_id' => data_get($page, 'instagram_business_account.id'), + 'ig_username' => data_get($igData, 'username'), + 'ig_name' => data_get($igData, 'name'), + 'ig_picture' => data_get($igData, 'profile_picture_url'), + ]; + }); + } + + private function graphApi(): string + { + return (string) config('trypost.platforms.instagram-facebook.graph_api'); + } + private function graphVersion(): string { return Uri::of(config('trypost.platforms.instagram-facebook.graph_api'))->path(); diff --git a/app/Services/Social/Meta/GrantedPermissions.php b/app/Services/Social/Meta/GrantedPermissions.php new file mode 100644 index 000000000..625fc1919 --- /dev/null +++ b/app/Services/Social/Meta/GrantedPermissions.php @@ -0,0 +1,51 @@ + $requested + * @return array + */ + public static function for(string $graphApi, string $userToken, array $requested): array + { + try { + $response = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/me/permissions", [ + 'access_token' => $userToken, + ]); + } catch (ConnectionException) { + return $requested; + } + + if ($response->failed()) { + return $requested; + } + + $granted = $response->collect('data') + ->filter(fn ($permission) => data_get($permission, 'status') === 'granted') + ->pluck('permission') + ->filter() + ->map(strval(...)) + ->values() + ->all(); + + return $granted === [] ? $requested : $granted; + } +} diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 1a130b664..a468a872f 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -57,12 +57,28 @@ public static function forUser(string $graphApi, string $userToken, string $fiel } return $pages - ->filter(fn (array $page) => filled(data_get($page, 'access_token'))) ->unique(fn (array $page) => (string) data_get($page, 'id')) ->values() ->all(); } + /** + * The Pages this login can actually post to. A Page Meta lists without an + * `access_token` — the shape of a login that declined `pages_read_engagement` + * on Meta's per-permission toggles — would connect into an account that + * cannot publish, so callers separate it from a Page they never had. + * + * @param array> $pages + * @return list> + */ + public static function publishable(array $pages): array + { + return collect($pages) + ->filter(fn (array $page) => filled(data_get($page, 'access_token'))) + ->values() + ->all(); + } + /** * @return list */ diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 088618db2..3c24a458c 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'فشل جلب الملف الشخصي.', 'page_not_found' => 'لم يتم العثور على الصفحة.', 'channel_not_found' => 'لم يتم العثور على القناة.', + 'pages_missing_permission' => 'وجدنا صفحاتك ولكن ليس إذن النشر فيها. أعد الاتصال واقبل جميع الأذونات.', 'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.', 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 6bde74a52..c518b4583 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -157,6 +157,7 @@ 'failed_to_get_profile' => 'Profil konnte nicht abgerufen werden.', 'page_not_found' => 'Seite nicht gefunden.', 'channel_not_found' => 'Kanal nicht gefunden.', + 'pages_missing_permission' => 'Wir haben deine Seiten gefunden, aber nicht die Berechtigung, dort zu posten. Verbinde erneut und akzeptiere alle Berechtigungen.', 'no_facebook_pages' => 'Keine Facebook-Seiten gefunden. Du musst Administrator mindestens einer Seite sein.', 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', 'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 1a853c8d4..84c932fc9 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Η ανάκτηση του προφίλ απέτυχε.', 'page_not_found' => 'Η σελίδα δεν βρέθηκε.', 'channel_not_found' => 'Το κανάλι δεν βρέθηκε.', + 'pages_missing_permission' => 'Βρήκαμε τις Σελίδες σας, αλλά όχι την άδεια δημοσίευσης σε αυτές. Συνδεθείτε ξανά και αποδεχτείτε όλες τις άδειες.', 'no_facebook_pages' => 'Δεν βρέθηκαν σελίδες Facebook. Πρέπει να είστε διαχειριστής τουλάχιστον μίας σελίδας.', 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 893a43163..70e361aa1 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Failed to get profile.', 'page_not_found' => 'Page not found.', 'channel_not_found' => 'Channel not found.', + 'pages_missing_permission' => 'We found your Pages but not the permission to post to them. Reconnect and accept every permission.', 'no_facebook_pages' => 'No Facebook Pages found. You need to be an admin of at least one page.', 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 78dac79bc..e066ed42b 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Falló al obtener el perfil.', 'page_not_found' => 'Página no encontrada.', 'channel_not_found' => 'Canal no encontrado.', + 'pages_missing_permission' => 'Encontramos tus páginas, pero no el permiso para publicar en ellas. Vuelve a conectar y acepta todos los permisos.', 'no_facebook_pages' => 'No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.', 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index e8d1c462f..81a9f2377 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Impossible de récupérer le profil.', 'page_not_found' => 'Page introuvable.', 'channel_not_found' => 'Chaîne introuvable.', + 'pages_missing_permission' => 'Nous avons trouvé vos Pages, mais pas l’autorisation d’y publier. Reconnectez-vous en acceptant toutes les autorisations.', 'no_facebook_pages' => 'Aucune page Facebook trouvée. Vous devez être administrateur d\'au moins une page.', 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', 'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 54a95b797..8a6e0d71f 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Impossibile ottenere il profilo.', 'page_not_found' => 'Pagina non trovata.', 'channel_not_found' => 'Canale non trovato.', + 'pages_missing_permission' => 'Abbiamo trovato le tue Pagine, ma non l’autorizzazione a pubblicarci. Riconnetti accettando tutte le autorizzazioni.', 'no_facebook_pages' => 'Nessuna pagina Facebook trovata. Devi essere amministratore di almeno una pagina.', 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', 'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 2fbe1753b..ae58d2f1e 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'プロフィールの取得に失敗しました。', 'page_not_found' => 'ページが見つかりません。', 'channel_not_found' => 'チャンネルが見つかりません。', + 'pages_missing_permission' => 'ページは見つかりましたが、投稿する権限がありません。再接続してすべての権限を許可してください。', 'no_facebook_pages' => 'Facebook ページが見つかりません。少なくとも 1 つのページの管理者である必要があります。', 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index e41912efc..0234be4fa 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => '프로필을 가져오지 못했습니다.', 'page_not_found' => '페이지를 찾을 수 없습니다.', 'channel_not_found' => '채널을 찾을 수 없습니다.', + 'pages_missing_permission' => '페이지는 찾았지만 게시 권한이 없습니다. 다시 연결하고 모든 권한을 허용해 주세요.', 'no_facebook_pages' => 'Facebook 페이지를 찾을 수 없습니다. 최소 한 개 페이지의 관리자여야 합니다.', 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index aa69e8afb..ccc420a35 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Kon profiel niet ophalen.', 'page_not_found' => 'Pagina niet gevonden.', 'channel_not_found' => 'Kanaal niet gevonden.', + 'pages_missing_permission' => 'We hebben je pagina’s gevonden, maar niet de rechten om erop te posten. Maak opnieuw verbinding en accepteer alle rechten.', 'no_facebook_pages' => 'Geen Facebook-pagina\'s gevonden. Je moet beheerder zijn van ten minste één pagina.', 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', 'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index ed8d13e2d..3d4833ab6 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Nie udało się pobrać profilu.', 'page_not_found' => 'Nie znaleziono strony.', 'channel_not_found' => 'Nie znaleziono kanału.', + 'pages_missing_permission' => 'Znaleźliśmy Twoje strony, ale nie uprawnienia do publikowania na nich. Połącz ponownie i zaakceptuj wszystkie uprawnienia.', 'no_facebook_pages' => 'Nie znaleziono stron na Facebooku. Musisz być administratorem co najmniej jednej strony.', 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', 'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 69a76812e..ccf75e590 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Falha ao obter perfil.', 'page_not_found' => 'Página não encontrada.', 'channel_not_found' => 'Canal não encontrado.', + 'pages_missing_permission' => 'Encontramos suas páginas, mas não a permissão para publicar nelas. Reconecte aceitando todas as permissões.', 'no_facebook_pages' => 'Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.', 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 3fbd86903..00645921e 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Не удалось получить профиль.', 'page_not_found' => 'Страница не найдена.', 'channel_not_found' => 'Канал не найден.', + 'pages_missing_permission' => 'Мы нашли ваши страницы, но не разрешение публиковать на них. Подключитесь заново и примите все разрешения.', 'no_facebook_pages' => 'Страницы Facebook не найдены. Вы должны быть администратором хотя бы одной страницы.', 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 0e6a14807..21905a21b 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -157,6 +157,7 @@ 'failed_to_get_profile' => 'Profil alınamadı.', 'page_not_found' => 'Sayfa bulunamadı.', 'channel_not_found' => 'Kanal bulunamadı.', + 'pages_missing_permission' => 'Sayfalarınızı bulduk ama orada paylaşım izni bulamadık. Yeniden bağlanıp tüm izinleri kabul edin.', 'no_facebook_pages' => 'Facebook Sayfası bulunamadı. En az bir sayfanın yöneticisi olmanız gerekir.', 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', 'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index b7d72f6f4..a4c555cb5 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Не вдалося отримати профіль.', 'page_not_found' => 'Сторінку не знайдено.', 'channel_not_found' => 'Канал не знайдено.', + 'pages_missing_permission' => 'Ми знайшли ваші сторінки, але не дозвіл публікувати на них. Підключіться знову та надайте всі дозволи.', 'no_facebook_pages' => 'Сторінок Facebook не знайдено. Ви маєте бути адміністратором хоча б однієї сторінки.', 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 7ab84b919..737eb20fd 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => '获取主页信息失败。', 'page_not_found' => '未找到页面。', 'channel_not_found' => '未找到频道。', + 'pages_missing_permission' => '我们找到了你的主页,但没有发布权限。请重新连接并接受所有权限。', 'no_facebook_pages' => '未找到 Facebook 主页。你至少需要是一个主页的管理员。', 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index ba0a230d4..57651c330 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -59,6 +59,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -110,6 +111,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -150,6 +152,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -192,6 +195,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -222,6 +226,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400), @@ -255,6 +260,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -312,6 +318,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -364,6 +371,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -424,6 +432,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -720,6 +729,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -781,6 +791,7 @@ ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -887,6 +898,7 @@ ->andReturn($driverMock); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -922,6 +934,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), @@ -968,6 +981,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), @@ -996,6 +1010,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), @@ -1064,6 +1079,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ @@ -1097,3 +1113,113 @@ expect(collect(session('facebook_oauth.pages'))->pluck('id')->all()) ->toBe(['page_role', 'page_portfolio']); }); + +test('facebook callback says the permission is missing when meta lists a page without a token', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'pages_show_list', 'status' => 'granted'], + ['permission' => 'pages_read_engagement', 'status' => 'declined'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_123', 'name' => 'My Page', 'picture' => ['data' => ['url' => null]]]], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.pages_missing_permission'))); + + $this->assertDatabaseCount('social_accounts', 0); +}); + +test('facebook stores the permissions meta granted, not the ones asked for', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'pages_show_list', 'status' => 'granted'], + ['permission' => 'pages_manage_posts', 'status' => 'granted'], + ['permission' => 'business_management', 'status' => 'declined'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [[ + 'id' => 'page_123', + 'name' => 'My Page', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes) + ->toBe(['pages_show_list', 'pages_manage_posts']); +}); + +test('facebook falls back to the requested scopes when meta will not list permissions', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['error' => ['message' => 'nope']], 500), + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [[ + 'id' => 'page_123', + 'name' => 'My Page', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes) + ->toContain('business_management'); +}); diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index f7a3c6695..2093287d9 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -45,6 +45,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -92,7 +93,7 @@ ->and(data_get(session('instagram_facebook_oauth.pages'), '0.ig_id'))->toBe('ig_1') ->and(data_get(session('instagram_facebook_oauth.pages'), '1.ig_id'))->toBe('ig_2'); - Http::assertSentCount(6); // /me + 2 accounts pages + /me/businesses + 2 IG lookups + Http::assertSentCount(7); // /me + /me/permissions + 2 accounts pages + /me/businesses + 2 IG lookups }); test('instagram-facebook callback connects page when first accounts response is empty', function () { @@ -117,6 +118,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -178,6 +180,7 @@ $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response([ @@ -230,6 +233,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -299,6 +303,7 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() @@ -498,6 +503,7 @@ ->andReturn(Mockery::mock(['user' => $socialiteUser])); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), 'https://graph.facebook.com/*/me/accounts*' => Http::response([ @@ -548,6 +554,7 @@ $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ + 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), @@ -581,3 +588,83 @@ 'status' => Status::Connected->value, ]); }); + +test('instagram via facebook describes every page in rounds without serialising them', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + $pages = collect(range(1, 45))->map(fn (int $n) => [ + 'id' => "page_{$n}", + 'name' => "Page {$n}", + 'picture' => ['data' => ['url' => null]], + 'access_token' => "page-token-{$n}", + 'instagram_business_account' => ['id' => "ig_{$n}"], + ])->all(); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => $pages], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/ig_*" => Http::response(['username' => 'an_account'], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback')); + + $response->assertRedirect(route('app.social.instagram-facebook.select-page')); + expect(session('instagram_facebook_oauth.pages'))->toHaveCount(45); + + Http::assertSentCount(4 + 45); +}); + +test('instagram via facebook says the permission is missing when meta lists a page without a token', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_1', + 'name' => 'Page', + 'picture' => ['data' => ['url' => null]], + 'instagram_business_account' => ['id' => 'ig_1'], + ]]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback')); + + $response->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.pages_missing_permission'))); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 2279df94f..263c5d5e1 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -59,7 +59,7 @@ function managedPagesGraphApi(): string ->and(data_get($pages, '0.access_token'))->toBe('role-token'); }); -test('portfolio pages the login cannot get a token for are dropped', function () { +test('every page meta lists is returned, token or not', function () { $graphApi = managedPagesGraphApi(); Http::fake([ @@ -76,8 +76,17 @@ function managedPagesGraphApi(): string $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - expect($pages)->toHaveCount(1) - ->and(data_get($pages, '0.id'))->toBe('page_2'); + expect(collect($pages)->pluck('id')->all())->toBe(['page_1', 'page_2']); +}); + +test('only the pages carrying a token are publishable', function () { + $publishable = ManagedPages::publishable([ + ['id' => 'page_1', 'name' => 'No Access'], + ['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'], + ['id' => 'page_3', 'name' => 'Empty Token', 'access_token' => ''], + ]); + + expect(collect($publishable)->pluck('id')->all())->toBe(['page_2']); }); test('a login without business_management keeps the pages me/accounts returned', function () { From d5bc9679c068c42ea11a811541ec0de66d190ae8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:16:32 -0300 Subject: [PATCH 10/29] fix: only drop a scope Meta says was refused PublishToSocialPlatform::failForMissingScopes() blocks a post when a platform's required publish scope is absent from the account's scopes column, so writing that column from /me/permissions can dead-end an account. Meta does not document that the endpoint echoes scope strings verbatim, and the edge is paginated, so a scope it never mentions is unknown rather than refused and stays. Only declined and expired drop. --- .../Social/Meta/GrantedPermissions.php | 30 ++++++++----- .../Feature/Social/FacebookControllerTest.php | 42 ++++++++++++++++++- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/app/Services/Social/Meta/GrantedPermissions.php b/app/Services/Social/Meta/GrantedPermissions.php index 625fc1919..ab729e2f4 100644 --- a/app/Services/Social/Meta/GrantedPermissions.php +++ b/app/Services/Social/Meta/GrantedPermissions.php @@ -8,18 +8,27 @@ use Illuminate\Support\Facades\Http; /** - * The permissions a Meta login actually granted. + * The scopes a Meta login is not known to have refused. * * Meta lets someone decline individual permissions in the consent dialog, so the * scope list an app asked for is a request, not a record. Storing it on the * account claims access the login may have refused — `business_management` above * all, which also needs Advanced Access and is declined by default without it. * - * When Meta cannot be asked, the requested list stands: no worse than recording - * the request, and never an account whose stored scopes are empty. + * Only a scope Meta explicitly reports as declined or expired is dropped. A scope + * it does not mention is kept: `/me/permissions` is paginated and Meta does not + * document that it echoes scope strings verbatim, so an absence is unknown, not a + * refusal — and PublishToSocialPlatform::failForMissingScopes() blocks publishing + * on a scope missing from this column. Guessing there would turn a cosmetic + * inaccuracy into dead accounts. */ class GrantedPermissions { + /** + * Statuses that mean this login will not act on the scope. + */ + private const REFUSED = ['declined', 'expired']; + /** * @param array $requested * @return array @@ -38,14 +47,15 @@ public static function for(string $graphApi, string $userToken, array $requested return $requested; } - $granted = $response->collect('data') - ->filter(fn ($permission) => data_get($permission, 'status') === 'granted') - ->pluck('permission') - ->filter() - ->map(strval(...)) + $reported = $response->collect('data')->keyBy(fn ($permission) => data_get($permission, 'permission')); + + return collect($requested) + ->reject(fn (string $scope) => in_array( + data_get($reported, "{$scope}.status"), + self::REFUSED, + true, + )) ->values() ->all(); - - return $granted === [] ? $requested : $granted; } } diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index 57651c330..996a95bb6 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -1150,7 +1150,7 @@ $this->assertDatabaseCount('social_accounts', 0); }); -test('facebook stores the permissions meta granted, not the ones asked for', function () { +test('facebook drops a scope meta reports as declined', function () { session([ 'social_connect_workspace' => $this->workspace->id, ]); @@ -1186,7 +1186,45 @@ $this->actingAs($this->user)->get(route('app.social.facebook.callback')); expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes) - ->toBe(['pages_show_list', 'pages_manage_posts']); + ->toContain('pages_manage_posts') + ->not->toContain('business_management'); +}); + +test('facebook keeps a scope meta never mentions rather than guessing it was refused', function () { + session([ + 'social_connect_workspace' => $this->workspace->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'public_profile', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [[ + 'id' => 'page_123', + 'name' => 'My Page', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.facebook.callback')); + + expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes) + ->toContain('pages_manage_posts'); }); test('facebook falls back to the requested scopes when meta will not list permissions', function () { From 7bfb8dfea002424e2090103ecfbf1f36eb57b429 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:28:06 -0300 Subject: [PATCH 11/29] fix: keep the portfolio walk honest and cheap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raise instead of truncating. The ceiling logged a warning and returned whatever fit, which is the one thing this module refuses to do everywhere else: if the walk cannot finish, the real list is unknown, and a truncated list holding exactly one Page would have been auto-connected without ever showing the picker. It now raises, and the ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no longer pays for it serially. Read the edges concurrently. Up to two paginated edges per portfolio ran back to back inside the OAuth callback. They run in rounds now; a URL that does not come back cleanly still goes through GraphPaginator, which owns the single place that logs a Graph failure and decides whether it is a rejection or an unknown. Prefer the record that carries a token. Merging kept whichever copy of a Page id arrived first, and /me/accounts always arrives first — so a Page listed there without a token buried the portfolio copy that had one, and the login was told its permission was missing for a Page it could reach. Describe only the Instagram accounts that survive. The per-Page lookup ran before filterConnectableIdentities discarded them, spending a BUC-rate-limited call on every Page only to throw the answer away. The filter reads instagram_business_account.id straight off the raw Page, so it needs no lookup to run first. Two Instagram tests mocked Socialite without usingGraphVersion, so the callback threw, the generic catch answered, and asserting only success=false passed on the error path instead of the one under test. --- .../Auth/InstagramFacebookController.php | 13 ++- app/Services/Social/Meta/ManagedPages.php | 100 +++++++++++++----- .../InstagramFacebookControllerTest.php | 62 ++++++++++- tests/Unit/Social/Meta/ManagedPagesTest.php | 49 +++++++-- 4 files changed, 184 insertions(+), 40 deletions(-) diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 8d6ea90b2..6ea15c2c2 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -99,14 +99,19 @@ public function callback(Request $request): InertiaResponse|RedirectResponse : 'accounts.popup_callback.pages_missing_permission'), $this->platform->value); } - $pages = $this->describeInstagramAccounts($publishable); - - $pages = $this->filterConnectableIdentities($workspace, $pages, 'ig_id', $existingAccount); + $connectable = $this->filterConnectableIdentities( + $workspace, + $publishable, + 'instagram_business_account.id', + $existingAccount, + ); - if (empty($pages)) { + if (empty($connectable)) { return $this->noConnectableIdentities($existingAccount, 'page_not_found'); } + $pages = $this->describeInstagramAccounts($connectable); + if (count($pages) === 1) { return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount, $granted); } diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index a468a872f..907092a0f 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -5,7 +5,12 @@ namespace App\Services\Social\Meta; use App\Exceptions\Social\IncompleteMetaGraphPaginationException; +use Illuminate\Http\Client\Pool; +use Illuminate\Http\Client\Response; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Uri; /** * Every Facebook Page a login can publish to, gathered from all the edges Meta lists them under. @@ -19,19 +24,26 @@ * Those edges need `business_management`, which a login may not grant, so a rejection * there reads as "this login reaches no portfolio pages" rather than failing the * connect. A throttle or an upstream hiccup is not a rejection — it leaves the real - * list unknown, and is raised so no caller auto-connects a half-fetched list. + * list unknown, and is raised so no caller auto-connects a half-fetched list. Running + * out of ceiling is the same kind of unknown and is raised too. */ class ManagedPages { private const PER_PAGE = 100; /** - * Hard ceiling on portfolios walked. Each one costs two more paginated - * edges inside a synchronous OAuth callback, so this plays the same role - * for the portfolio loop that GraphPaginator::MAX_PAGES plays for a single - * edge: far above any real membership, there only to bound a runaway. + * Portfolio edges read concurrently per round. The walk sits inside a + * synchronous OAuth callback, where serial round trips are what break it. */ - public const MAX_PORTFOLIOS = 25; + private const EDGES_PER_ROUND = 20; + + /** + * Hard ceiling on portfolios walked, matching GraphPaginator::MAX_PAGES in + * spirit: far above any real membership, there only to bound a runaway. + * Passing it means the real list is unknown, so it raises rather than + * quietly handing back whatever fit. + */ + public const MAX_PORTFOLIOS = 100; /** * @return list> @@ -40,23 +52,11 @@ class ManagedPages */ public static function forUser(string $graphApi, string $userToken, string $fields): array { - $pages = collect(GraphPaginator::all("{$graphApi}/me/accounts", [ - 'access_token' => $userToken, - 'fields' => $fields, - 'limit' => self::PER_PAGE, - ])); - - foreach (self::businessIds($graphApi, $userToken) as $businessId) { - foreach (['owned_pages', 'client_pages'] as $edge) { - $pages = $pages->concat(self::optional("{$graphApi}/{$businessId}/{$edge}", [ - 'access_token' => $userToken, - 'fields' => $fields, - 'limit' => self::PER_PAGE, - ])); - } - } + $query = ['access_token' => $userToken, 'fields' => $fields, 'limit' => self::PER_PAGE]; - return $pages + return collect(GraphPaginator::all("{$graphApi}/me/accounts", $query)) + ->concat(self::portfolioPages($graphApi, $userToken, $query)) + ->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1) ->unique(fn (array $page) => (string) data_get($page, 'id')) ->values() ->all(); @@ -79,8 +79,54 @@ public static function publishable(array $pages): array ->all(); } + /** + * @param array $query + * @return Collection> + */ + private static function portfolioPages(string $graphApi, string $userToken, array $query): Collection + { + return collect(self::businessIds($graphApi, $userToken)) + ->crossJoin(['owned_pages', 'client_pages']) + ->map(fn (array $edge) => Uri::of("{$graphApi}/{$edge[0]}/{$edge[1]}")->withQuery($query)->value()) + ->chunk(self::EDGES_PER_ROUND) + ->flatMap(self::readRound(...)); + } + + /** + * Reads a round of edges at once. A URL that does not come back cleanly is + * handed to GraphPaginator, which owns the one place that logs a Graph + * failure and decides whether it is a rejection or an unknown. + * + * @param Collection $urls + * @return Collection> + */ + private static function readRound(Collection $urls): Collection + { + $urls = $urls->values(); + + $responses = Http::pool(fn (Pool $pool) => $urls + ->map(fn (string $url) => $pool->timeout(15)->connectTimeout(5)->get($url)) + ->all()); + + return $urls->flatMap(function (string $url, int $index) use ($responses) { + $response = data_get($responses, $index); + + if (! $response instanceof Response || $response->failed()) { + return self::optional($url); + } + + $next = $response->json('paging.next'); + + return $response->collect('data')->concat( + is_string($next) && filled($next) ? self::optional($next) : [], + ); + }); + } + /** * @return list + * + * @throws IncompleteMetaGraphPaginationException */ private static function businessIds(string $graphApi, string $userToken): array { @@ -94,13 +140,15 @@ private static function businessIds(string $graphApi, string $userToken): array ->values(); if ($ids->count() > self::MAX_PORTFOLIOS) { - Log::warning('Meta portfolio walk truncated', [ + Log::error('Meta portfolio walk stopped: ceiling reached', [ 'found' => $ids->count(), - 'walked' => self::MAX_PORTFOLIOS, + 'ceiling' => self::MAX_PORTFOLIOS, ]); + + throw new IncompleteMetaGraphPaginationException; } - return $ids->take(self::MAX_PORTFOLIOS)->all(); + return $ids->all(); } /** @@ -114,7 +162,7 @@ private static function businessIds(string $graphApi, string $userToken): array * * @throws IncompleteMetaGraphPaginationException */ - private static function optional(string $url, array $query): array + private static function optional(string $url, array $query = []): array { try { return GraphPaginator::all($url, $query); diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index 2093287d9..35a0648db 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -500,7 +500,11 @@ Socialite::shouldReceive('driver') ->with('facebook') - ->andReturn(Mockery::mock(['user' => $socialiteUser])); + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); Http::fake([ 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), @@ -526,7 +530,7 @@ $this->actingAs($this->user) ->get(route('app.social.instagram-facebook.callback')) ->assertOk() - ->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); + ->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)->where('message', __('accounts.popup_callback.all_connected'))); expect($this->workspace->socialAccounts() ->where('platform', Platform::InstagramFacebook->value) @@ -668,3 +672,57 @@ ->where('success', false) ->where('message', __('accounts.popup_callback.pages_missing_permission'))); }); + +test('instagram via facebook does not describe a page it is about to discard', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Instagram, + 'platform_user_id' => 'ig_taken', + ]); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [ + [ + 'id' => 'page_taken', + 'name' => 'Already Connected', + 'access_token' => 'taken-token', + 'instagram_business_account' => ['id' => 'ig_taken'], + ], + [ + 'id' => 'page_free', + 'name' => 'Still Free', + 'access_token' => 'free-token', + 'instagram_business_account' => ['id' => 'ig_free'], + ], + ]], 200), + "{$graphApi}/ig_free*" => Http::response(['username' => 'free_account'], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.instagram-facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_taken')); + + expect(SocialAccount::where('platform_user_id', 'ig_free')->sole()->username)->toBe('free_account'); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 263c5d5e1..840927a42 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -76,7 +76,28 @@ function managedPagesGraphApi(): string $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - expect(collect($pages)->pluck('id')->all())->toBe(['page_1', 'page_2']); + expect(collect($pages)->pluck('id')->sort()->values()->all())->toBe(['page_1', 'page_2']); +}); + +test('a page reached with a token wins over the same page reached without one', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'No Token Here']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Same Page', 'access_token' => 'portfolio-token']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(1) + ->and(data_get($pages, '0.access_token'))->toBe('portfolio-token') + ->and(ManagedPages::publishable($pages))->toHaveCount(1); }); test('only the pages carrying a token are publishable', function () { @@ -243,19 +264,31 @@ function managedPagesGraphApi(): string ->and(data_get($pages, '0.id'))->toBe('page_1'); }); -test('the portfolio walk stops at the ceiling and says so', function () { +test('the portfolio walk refuses to hand back a list it could not finish', function () { $graphApi = managedPagesGraphApi(); - $portfolios = collect(range(1, ManagedPages::MAX_PORTFOLIOS + 5)) + $portfolios = collect(range(1, ManagedPages::MAX_PORTFOLIOS + 1)) ->map(fn (int $n) => ['id' => "biz_{$n}"]) ->all(); - Log::shouldReceive('warning') + Log::shouldReceive('error') ->once() - ->with('Meta portfolio walk truncated', [ - 'found' => ManagedPages::MAX_PORTFOLIOS + 5, - 'walked' => ManagedPages::MAX_PORTFOLIOS, + ->with('Meta portfolio walk stopped: ceiling reached', [ + 'found' => ManagedPages::MAX_PORTFOLIOS + 1, + 'ceiling' => ManagedPages::MAX_PORTFOLIOS, ]); + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); +})->throws(IncompleteMetaGraphPaginationException::class); + +test('portfolio edges are read concurrently rather than one after another', function () { + $graphApi = managedPagesGraphApi(); + $portfolios = collect(range(1, 30))->map(fn (int $n) => ['id' => "biz_{$n}"])->all(); + Http::fake([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), @@ -264,5 +297,5 @@ function managedPagesGraphApi(): string ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - Http::assertSentCount(1 + 1 + (ManagedPages::MAX_PORTFOLIOS * 2)); + Http::assertSentCount(2 + (30 * 2)); }); From e64d5df3bc05738b610e31278cbff7b67d3f6ba5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:33:16 -0300 Subject: [PATCH 12/29] test: pin that pages survive past the first pooled round The concurrency test drove 30 portfolios with every edge empty, so the merge across rounds was never exercised with data in it. --- tests/Unit/Social/Meta/ManagedPagesTest.php | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 840927a42..2a93d7937 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -299,3 +299,28 @@ function managedPagesGraphApi(): string Http::assertSentCount(2 + (30 * 2)); }); + +test('pages from every round survive the merge, not just the first', function () { + $graphApi = managedPagesGraphApi(); + $portfolios = collect(range(1, 26))->map(fn (int $n) => ['id' => "biz_{$n}"])->all(); + + $fakes = [ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), + ]; + + foreach (range(1, 26) as $n) { + $fakes["{$graphApi}/biz_{$n}/owned_pages*"] = Http::response([ + 'data' => [['id' => "page_{$n}", 'name' => "Page {$n}", 'access_token' => "token-{$n}"]], + ], 200); + $fakes["{$graphApi}/biz_{$n}/client_pages*"] = Http::response(['data' => []], 200); + } + + Http::fake($fakes); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + + expect($pages)->toHaveCount(26) + ->and(collect($pages)->pluck('id')->sort()->values()->all()) + ->toBe(collect(range(1, 26))->map(fn (int $n) => "page_{$n}")->sort()->values()->all()); +}); From 229dab642acb4dd0b80ea4cc3223ef5f03434698 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:34:59 -0300 Subject: [PATCH 13/29] fix: keep the paging-host guard on the pooled edge walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphPaginator refuses to follow a paging.next that points off the host the walk started from, so a tampered response cannot carry the access token somewhere else. Reading the first page of each edge through the pool and handing its paging.next straight back to GraphPaginator made that URL the *start* of a new walk, which is the one URL the guard trusts implicitly — so the first hop went unchecked. The host is compared before the hand-off now, and a mismatch re-walks the edge from the beginning so GraphPaginator's own guard is what refuses it, with its logging. --- app/Services/Social/Meta/ManagedPages.php | 20 +++++++++++++------ tests/Unit/Social/Meta/ManagedPagesTest.php | 22 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 907092a0f..279344e32 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -93,9 +93,11 @@ private static function portfolioPages(string $graphApi, string $userToken, arra } /** - * Reads a round of edges at once. A URL that does not come back cleanly is - * handed to GraphPaginator, which owns the one place that logs a Graph - * failure and decides whether it is a rejection or an unknown. + * Reads a round of edges at once. Anything that does not come back cleanly — + * a failure, or a `paging.next` pointing off the host the edge was read from — + * is handed to GraphPaginator, which owns the one place that logs a Graph + * failure, guards the paging host, and decides whether the failure is a + * rejection or an unknown. * * @param Collection $urls * @return Collection> @@ -117,9 +119,15 @@ private static function readRound(Collection $urls): Collection $next = $response->json('paging.next'); - return $response->collect('data')->concat( - is_string($next) && filled($next) ? self::optional($next) : [], - ); + if (! is_string($next) || blank($next)) { + return $response->collect('data'); + } + + if (Uri::of($next)->host() !== Uri::of($url)->host()) { + return self::optional($url); + } + + return $response->collect('data')->concat(self::optional($next)); }); } diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 2a93d7937..1a562523e 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -324,3 +324,25 @@ function managedPagesGraphApi(): string ->and(collect($pages)->pluck('id')->sort()->values()->all()) ->toBe(collect(range(1, 26))->map(fn (int $n) => "page_{$n}")->sort()->values()->all()); }); + +test('a portfolio edge paging off-host never gets the token', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => 'https://evil.example/owned_pages?access_token=user-token'], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + try { + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + } catch (IncompleteMetaGraphPaginationException) { + // The off-host walk aborts; what matters is where the token did not go. + } + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example')); +}); From 17a760dd96c9f8ced0c885cb7f3ba063857a9e0f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:43:25 -0300 Subject: [PATCH 14/29] fix: stop a cut-short walk from passing for a complete one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit optional() was written for "this edge may be forbidden" and answers a rejection with an empty list. Following paging.next through it gave a rejected cursor the same answer: page one of a 250-Page portfolio came back and the rest was dropped, and a single connectable Page in that fragment would have been auto-connected with no picker. The same hole sat on /me/businesses, where GraphPaginator is all-or-nothing — a failure on page two threw away the portfolios page one had already listed, degrading the connect back to /me/accounts alone in silence. The exception now carries how many pages arrived. Only a rejection on the very first request reads as "this edge is not readable"; anything after that is a fragment and raises. Cursors skip optional() entirely. A login Meta reports as refusing business_management also stops walking the edges at all. The controllers already read /me/permissions for the scopes column, so the answer costs nothing, and the walk was otherwise spending a request on a certain 403 — and logging it at error level — on every successful connect by such a login. An Instagram account with an empty Name connected as display_name null: data_get's default only fires on an absent key, and describeRound always writes the key. --- ...IncompleteMetaGraphPaginationException.php | 11 +++- .../Controllers/Auth/FacebookController.php | 9 +++- .../Auth/InstagramFacebookController.php | 8 +-- app/Services/Social/Meta/GraphPaginator.php | 1 + app/Services/Social/Meta/ManagedPages.php | 40 ++++++++++----- .../InstagramFacebookControllerTest.php | 34 +++++++++++++ tests/Unit/Social/Meta/ManagedPagesTest.php | 50 +++++++++++++++++++ 7 files changed, 133 insertions(+), 20 deletions(-) diff --git a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php index 550a1db80..0165e518c 100644 --- a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php +++ b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php @@ -18,11 +18,18 @@ * permission, where Meta has told us this login reaches nothing on that edge. * Only the latter is safe for a caller to read as an empty list; anything * unknown defaults to transient. + * + * `$fetched` counts the pages that did arrive. A rejection on the very first + * request means the edge was never readable; a rejection after that means a walk + * that had started got cut short, and what arrived is a fragment either way. */ class IncompleteMetaGraphPaginationException extends RuntimeException { - public function __construct(?Throwable $previous = null, public readonly bool $transient = true) - { + public function __construct( + ?Throwable $previous = null, + public readonly bool $transient = true, + public readonly int $fetched = 0, + ) { parent::__construct('Meta Graph pagination did not complete.', previous: $previous); } } diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 8111f349e..fb4f3696c 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -74,7 +74,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); - $listed = $this->fetchPages($socialUser->token); + $listed = $this->fetchPages($socialUser->token, $granted); $pages = ManagedPages::publishable($listed); if (empty($pages)) { @@ -227,12 +227,17 @@ public function select(Request $request): InertiaResponse } } - private function fetchPages(string $userToken): array + /** + * @param array $grantedScopes + * @return list> + */ + private function fetchPages(string $userToken, array $grantedScopes): array { $pages = ManagedPages::forUser( $this->graphApi(), $userToken, 'id,name,username,picture{url},access_token', + $grantedScopes, ); return collect($pages)->map(fn (array $page) => [ diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 6ea15c2c2..52d98d50f 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -90,7 +90,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); - $listed = $this->fetchPagesWithInstagram($socialUser->token); + $listed = $this->fetchPagesWithInstagram($socialUser->token, $granted); $publishable = ManagedPages::publishable($listed); if (empty($publishable)) { @@ -215,7 +215,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, (string) data_get($pageData, 'ig_id'), [ 'username' => data_get($pageData, 'ig_username'), - 'display_name' => data_get($pageData, 'ig_name', data_get($pageData, 'ig_username')), + 'display_name' => data_get($pageData, 'ig_name') ?? data_get($pageData, 'ig_username'), 'avatar_url' => $avatarPath, 'access_token' => data_get($pageData, 'page_access_token'), 'refresh_token' => null, @@ -240,14 +240,16 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, * in Meta's own shape — `access_token` still on each, so the caller can tell * a Page it cannot post to from one it never had. * + * @param array $grantedScopes * @return list> */ - private function fetchPagesWithInstagram(string $userToken): array + private function fetchPagesWithInstagram(string $userToken, array $grantedScopes): array { return collect(ManagedPages::forUser( $this->graphApi(), $userToken, 'id,name,username,picture{url},access_token,instagram_business_account', + $grantedScopes, )) ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) ->values() diff --git a/app/Services/Social/Meta/GraphPaginator.php b/app/Services/Social/Meta/GraphPaginator.php index 0ccc12f51..f0947acef 100644 --- a/app/Services/Social/Meta/GraphPaginator.php +++ b/app/Services/Social/Meta/GraphPaginator.php @@ -104,6 +104,7 @@ private static function abort( throw new IncompleteMetaGraphPaginationException( $e, transient: $response === null || GraphError::isTransientFailure($response), + fetched: $fetched, ); } } diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 279344e32..8bd959f41 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -21,11 +21,15 @@ * list there, so the portfolio's own `owned_pages` and `client_pages` edges are read * too and merged by Page id. * - * Those edges need `business_management`, which a login may not grant, so a rejection - * there reads as "this login reaches no portfolio pages" rather than failing the - * connect. A throttle or an upstream hiccup is not a rejection — it leaves the real - * list unknown, and is raised so no caller auto-connects a half-fetched list. Running - * out of ceiling is the same kind of unknown and is raised too. + * Those edges need `business_management`. A login Meta reports as having refused it + * skips them outright — walking anyway would spend a request on a certain 403 and + * log it at error level on every otherwise-successful connect. A refusal Meta does + * not report is not assumed: the walk runs, and a rejection there still reads as + * "this login reaches no portfolio pages" rather than failing the connect. + * + * A throttle or an upstream hiccup is not a rejection — it leaves the real list + * unknown, and is raised so no caller auto-connects a half-fetched list. Running out + * of ceiling is the same kind of unknown and is raised too. */ class ManagedPages { @@ -46,16 +50,24 @@ class ManagedPages public const MAX_PORTFOLIOS = 100; /** + * The permission Meta requires to read a portfolio's Page edges. + */ + private const PORTFOLIO_SCOPE = 'business_management'; + + /** + * @param array $grantedScopes * @return list> * * @throws IncompleteMetaGraphPaginationException */ - public static function forUser(string $graphApi, string $userToken, string $fields): array + public static function forUser(string $graphApi, string $userToken, string $fields, array $grantedScopes = [self::PORTFOLIO_SCOPE]): array { $query = ['access_token' => $userToken, 'fields' => $fields, 'limit' => self::PER_PAGE]; return collect(GraphPaginator::all("{$graphApi}/me/accounts", $query)) - ->concat(self::portfolioPages($graphApi, $userToken, $query)) + ->concat(in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true) + ? self::portfolioPages($graphApi, $userToken, $query) + : []) ->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1) ->unique(fn (array $page) => (string) data_get($page, 'id')) ->values() @@ -97,7 +109,8 @@ private static function portfolioPages(string $graphApi, string $userToken, arra * a failure, or a `paging.next` pointing off the host the edge was read from — * is handed to GraphPaginator, which owns the one place that logs a Graph * failure, guards the paging host, and decides whether the failure is a - * rejection or an unknown. + * rejection or an unknown. A cursor that fails after page one is a truncated + * walk, never a rejection, so it goes straight to GraphPaginator and raises. * * @param Collection $urls * @return Collection> @@ -127,7 +140,7 @@ private static function readRound(Collection $urls): Collection return self::optional($url); } - return $response->collect('data')->concat(self::optional($next)); + return $response->collect('data')->concat(GraphPaginator::all($next)); }); } @@ -161,9 +174,10 @@ private static function businessIds(string $graphApi, string $userToken): array /** * An edge this login is simply not allowed to read answers with an empty - * list. A throttle, an upstream hiccup or a truncated walk leaves the real - * list unknown, and is raised so the caller never auto-connects whatever - * happened to arrive first. + * list. Only a rejection on the very first request qualifies: once a page + * has arrived, a later failure is a walk cut short, and handing back the + * fragment would be the truncation this whole module refuses. A throttle or + * an upstream hiccup is never a rejection. * * @param array $query * @return list> @@ -175,7 +189,7 @@ private static function optional(string $url, array $query = []): array try { return GraphPaginator::all($url, $query); } catch (IncompleteMetaGraphPaginationException $e) { - if ($e->transient) { + if ($e->transient || $e->fetched > 0) { throw $e; } diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index 35a0648db..df366e97b 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -726,3 +726,37 @@ expect(SocialAccount::where('platform_user_id', 'ig_free')->sole()->username)->toBe('free_account'); }); + +test('instagram via facebook falls back to the username when meta returns a null name', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_1', + 'name' => 'Page', + 'access_token' => 'page-token', + 'instagram_business_account' => ['id' => 'ig_1'], + ]]], 200), + "{$graphApi}/ig_1*" => Http::response(['username' => 'only_a_handle', 'name' => null], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback')); + + expect(SocialAccount::where('platform_user_id', 'ig_1')->sole()->display_name)->toBe('only_a_handle'); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 1a562523e..0217431c2 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -346,3 +346,53 @@ function managedPagesGraphApi(): string Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example')); }); + +test('a cursor that fails after the first page is raised, not read as the whole edge', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::sequence() + ->push([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor1"], + ], 200) + ->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); +})->throws(IncompleteMetaGraphPaginationException::class); + +test('a portfolio list cut short mid-walk never silently drops the portfolios it did read', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::sequence() + ->push([ + 'data' => [['id' => 'biz_1']], + 'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"], + ], 200) + ->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400), + ]); + + ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); +})->throws(IncompleteMetaGraphPaginationException::class); + +test('a login meta reports as refusing business_management never touches the portfolio edges', function () { + $graphApi = managedPagesGraphApi(); + + Http::fake([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + ]); + + $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS, ['pages_show_list']); + + expect($pages)->toHaveCount(1); + Http::assertSentCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); +}); From 0a393f966e68279950d1670d8367156b814ce32f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 17:48:06 -0300 Subject: [PATCH 15/29] test: pin reconnecting a card only the portfolio still reaches A card whose Page moved behind a portfolio is the reconnect shape of the bug this branch fixes, and nothing covered it: the walk has to find the Page, and filterConnectableIdentities has to keep the original card rather than offering the portfolio's other Pages. --- .../Feature/Social/FacebookControllerTest.php | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index 996a95bb6..3d6473595 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -1261,3 +1261,64 @@ expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes) ->toContain('business_management'); }); + +test('facebook reconnects a card whose page is now only reachable through a portfolio', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Facebook, + 'platform_user_id' => 'page_portfolio', + 'access_token' => 'stale-token', + 'status' => Status::Disconnected, + ]); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'social_reconnect_id' => $account->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'business_management', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response(['data' => [ + [ + 'id' => 'page_portfolio', + 'name' => 'Reconnected Page', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'fresh-token', + ], + [ + 'id' => 'page_other', + 'name' => 'Someone Else', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'other-token', + ], + ]], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook->value)->count())->toBe(1); + + $account->refresh(); + + expect($account->access_token)->toBe('fresh-token') + ->and($account->display_name)->toBe('Reconnected Page') + ->and($account->status)->toBe(Status::Connected); +}); From 51743545cf9aa8f9780e36f6a2c85c009a29d606 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 18:02:48 -0300 Subject: [PATCH 16/29] fix: an unreadable portfolio must not deny the pages that were readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portfolio edges are additive, but every failure in them was raised and the callback's generic catch turned it into "error connecting" — so one throttled edge among sixty denied a login the Pages /me/accounts had already returned, and each retry burned more of the quota that caused it. Only /me/accounts failing is fatal now; everything else marks the walk incomplete and keeps what arrived. What the raise was protecting is kept where it belongs: a lone Page is only taken without asking when the walk saw everything, or when a reconnect has already pinned which Page is wanted. Otherwise the picker opens, and the login can see for itself that its Page is not there. The ceiling stops pretending. It compared the count after walking every page of /me/businesses — up to ten thousand ids — so the runaway it existed to bound had already happened. One request, one page, and more portfolios than that is an incomplete walk rather than a failed one. A pooled edge that fails is classified where it lands instead of being re-fetched, halving the cost of the common client_pages rejection, and GraphPaginator logs a confirmed rejection at warning: it is Meta answering the question, not something going wrong. A login that declined the permission its platform needs to publish is refused at connect. Meta issues a Page token off pages_show_list, so declining pages_manage_posts still produced a green account whose every scheduled post was then hard-failed by failForMissingScopes. Test fakes address Graph through the config rather than a literal host, which is what CLAUDE.md asks for and what the newer tests already did. --- .../Controllers/Auth/FacebookController.php | 30 +- .../Auth/InstagramFacebookController.php | 43 ++- app/Services/Social/Meta/GraphPaginator.php | 40 ++- app/Services/Social/Meta/ManagedPageList.php | 21 ++ app/Services/Social/Meta/ManagedPages.php | 194 ++++++----- .../Feature/Social/FacebookControllerTest.php | 209 +++++++++--- .../InstagramFacebookControllerTest.php | 34 +- tests/Unit/Social/Meta/GraphPaginatorTest.php | 4 +- tests/Unit/Social/Meta/ManagedPagesTest.php | 311 ++++++++---------- 9 files changed, 528 insertions(+), 358 deletions(-) create mode 100644 app/Services/Social/Meta/ManagedPageList.php diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index fb4f3696c..8c5208447 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -28,6 +28,8 @@ class FacebookController extends SocialController protected SocialPlatform $platform = SocialPlatform::Facebook; + private const PAGE_FIELDS = 'id,name,username,picture{url},access_token'; + protected array $scopes = [ 'public_profile', 'pages_show_list', @@ -74,7 +76,18 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); - $listed = $this->fetchPages($socialUser->token, $granted); + if (array_diff($this->platform->requiredPublishScopes(), $granted) !== []) { + return $this->popupCallback(false, __('accounts.popup_callback.pages_missing_permission'), $this->platform->value); + } + + $walk = ManagedPages::forUser( + $this->graphApi(), + $socialUser->token, + self::PAGE_FIELDS, + $granted, + ); + + $listed = $this->toPageCards($walk->pages); $pages = ManagedPages::publishable($listed); if (empty($pages)) { @@ -89,8 +102,8 @@ public function callback(Request $request): InertiaResponse|RedirectResponse return $this->noConnectableIdentities($reconnect, 'page_not_found'); } - // If only one page, connect directly - if (count($pages) === 1) { + // A lone page is only safe to take without asking when the walk saw everything + if (count($pages) === 1 && ($walk->complete || $reconnect !== null)) { $page = $pages[0]; $avatarPath = uploadFromUrl(data_get($page, 'picture')); @@ -228,18 +241,11 @@ public function select(Request $request): InertiaResponse } /** - * @param array $grantedScopes + * @param array> $pages * @return list> */ - private function fetchPages(string $userToken, array $grantedScopes): array + private function toPageCards(array $pages): array { - $pages = ManagedPages::forUser( - $this->graphApi(), - $userToken, - 'id,name,username,picture{url},access_token', - $grantedScopes, - ); - return collect($pages)->map(fn (array $page) => [ 'id' => data_get($page, 'id'), 'name' => data_get($page, 'name'), diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 52d98d50f..4a80fdce5 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -40,6 +40,8 @@ class InstagramFacebookController extends SocialController */ private const INSTAGRAM_LOOKUPS_PER_ROUND = 20; + private const PAGE_FIELDS = 'id,name,username,picture{url},access_token,instagram_business_account'; + protected array $scopes = [ 'public_profile', 'pages_show_list', @@ -90,7 +92,22 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); - $listed = $this->fetchPagesWithInstagram($socialUser->token, $granted); + if (array_diff($this->platform->requiredPublishScopes(), $granted) !== []) { + return $this->popupCallback(false, __('accounts.popup_callback.pages_missing_permission'), $this->platform->value); + } + + $walk = ManagedPages::forUser( + $this->graphApi(), + $socialUser->token, + self::PAGE_FIELDS, + $granted, + ); + + $listed = collect($walk->pages) + ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) + ->values() + ->all(); + $publishable = ManagedPages::publishable($listed); if (empty($publishable)) { @@ -112,7 +129,8 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $pages = $this->describeInstagramAccounts($connectable); - if (count($pages) === 1) { + // A lone page is only safe to take without asking when the walk saw everything + if (count($pages) === 1 && ($walk->complete || $existingAccount !== null)) { return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount, $granted); } @@ -235,27 +253,6 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, return $this->connectedCallback($existingAccount); } - /** - * The Pages this login lists that have an Instagram business account linked, - * in Meta's own shape — `access_token` still on each, so the caller can tell - * a Page it cannot post to from one it never had. - * - * @param array $grantedScopes - * @return list> - */ - private function fetchPagesWithInstagram(string $userToken, array $grantedScopes): array - { - return collect(ManagedPages::forUser( - $this->graphApi(), - $userToken, - 'id,name,username,picture{url},access_token,instagram_business_account', - $grantedScopes, - )) - ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) - ->values() - ->all(); - } - /** * @param array> $pages * @return list> diff --git a/app/Services/Social/Meta/GraphPaginator.php b/app/Services/Social/Meta/GraphPaginator.php index f0947acef..02eb328e2 100644 --- a/app/Services/Social/Meta/GraphPaginator.php +++ b/app/Services/Social/Meta/GraphPaginator.php @@ -83,6 +83,16 @@ public static function all(string $url, array $query = []): array return $items->values()->all(); } + /** + * Classify and log a single failed Graph response for a caller that read the + * page itself — a pooled first page — rather than walking it here. Keeps the + * one description of what a Graph failure means in this one place. + */ + public static function failure(string $url, Response $response): IncompleteMetaGraphPaginationException + { + return self::describe($url, 0, response: $response); + } + /** * @throws IncompleteMetaGraphPaginationException */ @@ -93,18 +103,34 @@ private static function abort( ?Response $response = null, ?string $reason = null, ): never { - Log::error($reason ?? ($e ? 'Meta Graph pagination connection failed' : 'Meta Graph pagination request failed'), array_filter([ + throw self::describe($url, $fetched, $e, $response, $reason); + } + + /** + * A confirmed rejection is Meta answering the question, and callers that treat + * it as "this edge is not readable" log nothing further — so it is a warning. + * Anything unknown leaves a walk in the dark and stays at error level. + */ + private static function describe( + string $url, + int $fetched, + ?Throwable $e = null, + ?Response $response = null, + ?string $reason = null, + ): IncompleteMetaGraphPaginationException { + $transient = $response === null || GraphError::isTransientFailure($response); + + $message = $reason ?? ($e ? 'Meta Graph pagination connection failed' : 'Meta Graph pagination request failed'); + $context = array_filter([ 'url' => TokenRedactor::redact($url), 'error' => $e?->getMessage(), 'status' => $response?->status(), 'body' => $response ? TokenRedactor::redact($response->body()) : null, 'fetched' => $fetched > 0 ? $fetched : null, - ])); + ]); + + $transient ? Log::error($message, $context) : Log::warning($message, $context); - throw new IncompleteMetaGraphPaginationException( - $e, - transient: $response === null || GraphError::isTransientFailure($response), - fetched: $fetched, - ); + return new IncompleteMetaGraphPaginationException($e, transient: $transient, fetched: $fetched); } } diff --git a/app/Services/Social/Meta/ManagedPageList.php b/app/Services/Social/Meta/ManagedPageList.php new file mode 100644 index 000000000..3c4a17b45 --- /dev/null +++ b/app/Services/Social/Meta/ManagedPageList.php @@ -0,0 +1,21 @@ +> $pages + */ + public function __construct(public array $pages, public bool $complete) {} +} diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 8bd959f41..9c6641cb4 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -5,11 +5,11 @@ namespace App\Services\Social\Meta; use App\Exceptions\Social\IncompleteMetaGraphPaginationException; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\Response; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Uri; /** @@ -27,9 +27,11 @@ * not report is not assumed: the walk runs, and a rejection there still reads as * "this login reaches no portfolio pages" rather than failing the connect. * - * A throttle or an upstream hiccup is not a rejection — it leaves the real list - * unknown, and is raised so no caller auto-connects a half-fetched list. Running out - * of ceiling is the same kind of unknown and is raised too. + * A throttle, an upstream hiccup or more portfolios than the ceiling walks leaves the + * real list unknown. None of that denies the connect — the Pages that did arrive are + * still returned — but the walk reports itself incomplete so no caller auto-connects + * off a list it cannot vouch for. Only `/me/accounts` itself failing is fatal: with + * nothing to stand on there is no list at all. */ class ManagedPages { @@ -42,10 +44,8 @@ class ManagedPages private const EDGES_PER_ROUND = 20; /** - * Hard ceiling on portfolios walked, matching GraphPaginator::MAX_PAGES in - * spirit: far above any real membership, there only to bound a runaway. - * Passing it means the real list is unknown, so it raises rather than - * quietly handing back whatever fit. + * Portfolios walked, and the page size asked of `/me/businesses` — the walk + * reads that one page and no more, so this is what bounds the whole thing. */ public const MAX_PORTFOLIOS = 100; @@ -56,22 +56,26 @@ class ManagedPages /** * @param array $grantedScopes - * @return list> * - * @throws IncompleteMetaGraphPaginationException + * @throws IncompleteMetaGraphPaginationException when `/me/accounts` itself fails */ - public static function forUser(string $graphApi, string $userToken, string $fields, array $grantedScopes = [self::PORTFOLIO_SCOPE]): array - { + public static function forUser( + string $graphApi, + string $userToken, + string $fields, + array $grantedScopes = [self::PORTFOLIO_SCOPE], + ): ManagedPageList { $query = ['access_token' => $userToken, 'fields' => $fields, 'limit' => self::PER_PAGE]; + $pages = collect(GraphPaginator::all("{$graphApi}/me/accounts", $query)); - return collect(GraphPaginator::all("{$graphApi}/me/accounts", $query)) - ->concat(in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true) - ? self::portfolioPages($graphApi, $userToken, $query) - : []) - ->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1) - ->unique(fn (array $page) => (string) data_get($page, 'id')) - ->values() - ->all(); + if (! in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true)) { + return new ManagedPageList(self::merge($pages), true); + } + + $complete = true; + $pages = $pages->concat(self::portfolioPages($graphApi, $userToken, $query, $complete)); + + return new ManagedPageList(self::merge($pages), $complete); } /** @@ -91,31 +95,51 @@ public static function publishable(array $pages): array ->all(); } + /** + * One record per Page id, preferring whichever copy carries a token. + * + * @param Collection> $pages + * @return list> + */ + private static function merge(Collection $pages): array + { + return $pages + ->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1) + ->unique(fn (array $page) => (string) data_get($page, 'id')) + ->values() + ->all(); + } + /** * @param array $query * @return Collection> */ - private static function portfolioPages(string $graphApi, string $userToken, array $query): Collection + private static function portfolioPages(string $graphApi, string $userToken, array $query, bool &$complete): Collection { - return collect(self::businessIds($graphApi, $userToken)) + $rounds = collect(self::businessIds($graphApi, $userToken, $complete)) ->crossJoin(['owned_pages', 'client_pages']) ->map(fn (array $edge) => Uri::of("{$graphApi}/{$edge[0]}/{$edge[1]}")->withQuery($query)->value()) - ->chunk(self::EDGES_PER_ROUND) - ->flatMap(self::readRound(...)); + ->chunk(self::EDGES_PER_ROUND); + + $pages = collect(); + + foreach ($rounds as $round) { + $pages = $pages->concat(self::readRound($round, $complete)); + } + + return $pages; } /** - * Reads a round of edges at once. Anything that does not come back cleanly — - * a failure, or a `paging.next` pointing off the host the edge was read from — - * is handed to GraphPaginator, which owns the one place that logs a Graph - * failure, guards the paging host, and decides whether the failure is a - * rejection or an unknown. A cursor that fails after page one is a truncated - * walk, never a rejection, so it goes straight to GraphPaginator and raises. + * Reads a round of edges at once, classifying each answer where it lands so a + * failure costs one request rather than two. A `paging.next` is followed only + * when it stays on the host the edge was read from; anything else is handed to + * GraphPaginator, whose own guard refuses it. * * @param Collection $urls * @return Collection> */ - private static function readRound(Collection $urls): Collection + private static function readRound(Collection $urls, bool &$complete): Collection { $urls = $urls->values(); @@ -123,77 +147,97 @@ private static function readRound(Collection $urls): Collection ->map(fn (string $url) => $pool->timeout(15)->connectTimeout(5)->get($url)) ->all()); - return $urls->flatMap(function (string $url, int $index) use ($responses) { + $pages = collect(); + + foreach ($urls as $index => $url) { $response = data_get($responses, $index); - if (! $response instanceof Response || $response->failed()) { - return self::optional($url); + if (! $response instanceof Response) { + $complete = false; + + continue; } + if ($response->failed()) { + $complete = GraphPaginator::failure($url, $response)->transient ? false : $complete; + + continue; + } + + $pages = $pages->concat($response->collect('data')); $next = $response->json('paging.next'); if (! is_string($next) || blank($next)) { - return $response->collect('data'); + continue; } - if (Uri::of($next)->host() !== Uri::of($url)->host()) { - return self::optional($url); - } + $pages = $pages->concat(self::rest( + Uri::of($next)->host() === Uri::of($url)->host() ? $next : $url, + $complete, + )); + } - return $response->collect('data')->concat(GraphPaginator::all($next)); - }); + return $pages; } /** - * @return list + * Follows what is left of an edge. Anything short of the whole remainder — a + * rejection included, since a page already arrived — leaves the walk unable to + * vouch for the edge. * - * @throws IncompleteMetaGraphPaginationException + * @return list> */ - private static function businessIds(string $graphApi, string $userToken): array + private static function rest(string $url, bool &$complete): array { - $ids = collect(self::optional("{$graphApi}/me/businesses", [ - 'access_token' => $userToken, - 'limit' => self::PER_PAGE, - ])) - ->pluck('id') - ->filter() - ->map(strval(...)) - ->values(); - - if ($ids->count() > self::MAX_PORTFOLIOS) { - Log::error('Meta portfolio walk stopped: ceiling reached', [ - 'found' => $ids->count(), - 'ceiling' => self::MAX_PORTFOLIOS, - ]); + try { + return GraphPaginator::all($url); + } catch (IncompleteMetaGraphPaginationException) { + $complete = false; - throw new IncompleteMetaGraphPaginationException; + return []; } - - return $ids->all(); } /** - * An edge this login is simply not allowed to read answers with an empty - * list. Only a rejection on the very first request qualifies: once a page - * has arrived, a later failure is a walk cut short, and handing back the - * fragment would be the truncation this whole module refuses. A throttle or - * an upstream hiccup is never a rejection. + * The portfolios to walk, from a single request. Reading only the first page + * is what actually bounds the work: paginating here would let one login spawn + * thousands of edge reads inside a synchronous OAuth callback. More portfolios + * than fit means the walk cannot see all of them, which is an incomplete walk, + * not a failed one. * - * @param array $query - * @return list> - * - * @throws IncompleteMetaGraphPaginationException + * @return list */ - private static function optional(string $url, array $query = []): array + private static function businessIds(string $graphApi, string $userToken, bool &$complete): array { + $url = "{$graphApi}/me/businesses"; + try { - return GraphPaginator::all($url, $query); - } catch (IncompleteMetaGraphPaginationException $e) { - if ($e->transient || $e->fetched > 0) { - throw $e; - } + $response = Http::timeout(15)->connectTimeout(5)->get($url, [ + 'access_token' => $userToken, + 'limit' => self::MAX_PORTFOLIOS, + ]); + } catch (ConnectionException) { + $complete = false; + + return []; + } + + if ($response->failed()) { + $complete = GraphPaginator::failure($url, $response)->transient ? false : $complete; return []; } + + if (filled($response->json('paging.next'))) { + $complete = false; + } + + return $response->collect('data') + ->pluck('id') + ->filter() + ->map(strval(...)) + ->take(self::MAX_PORTFOLIOS) + ->values() + ->all(); } } diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index 3d6473595..15d80d522 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -58,11 +58,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page_123', @@ -110,11 +112,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page_123', @@ -151,11 +155,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page_1', @@ -194,11 +200,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [], ], 200), ]); @@ -226,8 +234,8 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400), ]); @@ -260,8 +268,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -318,8 +326,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -371,8 +379,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -431,11 +439,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page_new', @@ -728,11 +738,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page_1', @@ -790,11 +802,13 @@ ->with('facebook') ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page_other', @@ -897,16 +911,18 @@ ->with('facebook') ->andReturn($driverMock); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ ['id' => 'page-1', 'name' => 'Only Page', 'access_token' => 'page-token'], ], ], 200), - 'https://graph.facebook.com/*' => Http::response(['id' => 'fb-user', 'name' => 'Me'], 200), + "{$graphApi}/*" => Http::response(['id' => 'fb-user', 'name' => 'Me'], 200), ]); $this->actingAs($this->user) @@ -934,7 +950,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), @@ -981,7 +997,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), @@ -1010,7 +1026,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), @@ -1079,7 +1095,7 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ @@ -1322,3 +1338,106 @@ ->and($account->display_name)->toBe('Reconnected Page') ->and($account->status)->toBe(Status::Connected); }); + +test('facebook refuses a login that declined the permission needed to publish', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'pages_manage_posts', 'status' => 'declined'], + ]], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.pages_missing_permission'))); + + $this->assertDatabaseCount('social_accounts', 0); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/accounts')); +}); + +test('facebook asks rather than auto-connecting a lone page found by an incomplete walk', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'business_management', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_1', + 'name' => 'The Only One We Saw', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'code' => 4], + ], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertRedirect(route('app.social.facebook.select-page')); + + expect(session('facebook_oauth.pages'))->toHaveCount(1); + $this->assertDatabaseCount('social_accounts', 0); +}); + +test('facebook still connects a lone page when the walk saw everything', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'business_management', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_1', + 'name' => 'The Only One', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $this->assertDatabaseCount('social_accounts', 1); +}); diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index df366e97b..f984e029c 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -45,8 +45,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -118,8 +118,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -180,8 +180,8 @@ $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ @@ -233,8 +233,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -303,8 +303,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), "{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::sequence() ->push([ @@ -506,11 +506,13 @@ ->shouldReceive('user')->andReturn($socialiteUser) ->getMock()); + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - 'https://graph.facebook.com/*/me?*' => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - 'https://graph.facebook.com/*/me/businesses*' => Http::response(['data' => []], 200), - 'https://graph.facebook.com/*/me/accounts*' => Http::response([ + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response([ 'data' => [ [ 'id' => 'page-1', @@ -520,7 +522,7 @@ ], ], ], 200), - 'https://graph.facebook.com/*' => Http::response([ + "{$graphApi}/*" => Http::response([ 'id' => 'shared-ig', 'username' => 'shared', 'name' => 'Shared', @@ -558,7 +560,7 @@ $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ - 'https://graph.facebook.com/*/me/permissions*' => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), diff --git a/tests/Unit/Social/Meta/GraphPaginatorTest.php b/tests/Unit/Social/Meta/GraphPaginatorTest.php index a023ed16e..011d0242f 100644 --- a/tests/Unit/Social/Meta/GraphPaginatorTest.php +++ b/tests/Unit/Social/Meta/GraphPaginatorTest.php @@ -116,7 +116,7 @@ $graphApi = 'https://graph.facebook.com/v25.0'; $nextUrl = "{$graphApi}/me/accounts?access_token=secret-token&after=cursor1&limit=100"; - Log::shouldReceive('error')->once()->withArgs(function (string $message, array $context) { + Log::shouldReceive('warning')->once()->withArgs(function (string $message, array $context) { return $message === 'Meta Graph pagination request failed' && ! str_contains((string) data_get($context, 'url'), 'secret-token') && str_contains((string) data_get($context, 'url'), 'access_token=[REDACTED]'); @@ -144,7 +144,7 @@ test('graph paginator throws when the first request fails', function () { Http::preventStrayRequests(); - Log::shouldReceive('error')->once()->withArgs(function (string $message) { + Log::shouldReceive('warning')->once()->withArgs(function (string $message) { return $message === 'Meta Graph pagination request failed'; }); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 0217431c2..1612ff91f 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Exceptions\Social\IncompleteMetaGraphPaginationException; +use App\Services\Social\Meta\ManagedPageList; use App\Services\Social\Meta\ManagedPages; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -11,6 +12,7 @@ beforeEach(function () { Http::preventStrayRequests(); + Log::spy(); }); function managedPagesGraphApi(): string @@ -18,31 +20,40 @@ function managedPagesGraphApi(): string return (string) config('trypost.platforms.facebook.graph_api'); } +function managedPagesWalk(array $extraFakes = [], array $granted = ['business_management']): ManagedPageList +{ + Http::fake($extraFakes); + + return ManagedPages::forUser(managedPagesGraphApi(), 'user-token', MANAGED_PAGES_FIELDS, $granted); +} + +function managedPagesIds(ManagedPageList $walk): array +{ + return collect($walk->pages)->pluck('id')->all(); +} + test('business portfolio pages are found when me/accounts is empty', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ - 'data' => [['id' => 'page_1', 'name' => 'Owned Page', 'access_token' => 'owned-token']], + 'data' => [['id' => 'page_1', 'name' => 'Owned', 'access_token' => 'owned-token']], ], 200), "{$graphApi}/biz_1/client_pages*" => Http::response([ - 'data' => [['id' => 'page_2', 'name' => 'Client Page', 'access_token' => 'client-token']], + 'data' => [['id' => 'page_2', 'name' => 'Client', 'access_token' => 'client-token']], ], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(2) - ->and(data_get($pages, '0.id'))->toBe('page_1') - ->and(data_get($pages, '1.id'))->toBe('page_2'); + expect(managedPagesIds($walk))->toBe(['page_1', 'page_2']) + ->and($walk->complete)->toBeTrue(); }); test('a page listed in both me/accounts and a portfolio is returned once, keeping its user token', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), @@ -53,51 +64,45 @@ function managedPagesGraphApi(): string "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(1) - ->and(data_get($pages, '0.access_token'))->toBe('role-token'); + expect($walk->pages)->toHaveCount(1) + ->and(data_get($walk->pages, '0.access_token'))->toBe('role-token'); }); -test('every page meta lists is returned, token or not', function () { +test('a page reached with a token wins over the same page reached without one', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'No Token Here']], + ], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ - 'data' => [ - ['id' => 'page_1', 'name' => 'No Access'], - ['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'], - ], + 'data' => [['id' => 'page_1', 'name' => 'Same Page', 'access_token' => 'portfolio-token']], ], 200), "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect(collect($pages)->pluck('id')->sort()->values()->all())->toBe(['page_1', 'page_2']); + expect($walk->pages)->toHaveCount(1) + ->and(data_get($walk->pages, '0.access_token'))->toBe('portfolio-token') + ->and(ManagedPages::publishable($walk->pages))->toHaveCount(1); }); -test('a page reached with a token wins over the same page reached without one', function () { +test('every page meta lists is returned, token or not', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response([ - 'data' => [['id' => 'page_1', 'name' => 'No Token Here']], - ], 200), + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ - 'data' => [['id' => 'page_1', 'name' => 'Same Page', 'access_token' => 'portfolio-token']], + 'data' => [ + ['id' => 'page_1', 'name' => 'No Access'], + ['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'], + ], ], 200), "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(1) - ->and(data_get($pages, '0.access_token'))->toBe('portfolio-token') - ->and(ManagedPages::publishable($pages))->toHaveCount(1); + expect(collect($walk->pages)->pluck('id')->sort()->values()->all())->toBe(['page_1', 'page_2']); }); test('only the pages carrying a token are publishable', function () { @@ -110,56 +115,82 @@ function managedPagesGraphApi(): string expect(collect($publishable)->pluck('id')->all())->toBe(['page_2']); }); -test('a login without business_management keeps the pages me/accounts returned', function () { +test('a login meta reports as refusing business_management never touches the portfolio edges', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + ], granted: ['pages_show_list']); + + expect($walk->pages)->toHaveCount(1) + ->and($walk->complete)->toBeTrue(); + + Http::assertSentCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); +}); + +test('a portfolio edge denied by permissions is a complete walk with nothing behind it', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), "{$graphApi}/me/businesses*" => Http::response([ - 'error' => ['message' => 'Requires business_management permission'], + 'error' => ['message' => 'Requires business_management permission', 'code' => 200], ], 403), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(1) - ->and(data_get($pages, '0.id'))->toBe('page_1'); + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeTrue(); }); -test('a failing portfolio edge keeps the pages me/accounts already returned', function () { +test('a throttled portfolio edge keeps the pages it has and admits it is incomplete', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), - "{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'nope']], 400), - "{$graphApi}/biz_1/client_pages*" => Http::response(['error' => ['message' => 'nope']], 400), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'code' => 4], + ], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); - expect($pages)->toHaveCount(1) - ->and(data_get($pages, '0.id'))->toBe('page_1'); +test('an upstream failure listing portfolios keeps the me/accounts pages', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['error' => ['message' => 'oops']], 500), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); }); test('a failing me/accounts still aborts instead of reporting no pages', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400), ]); - - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); })->throws(IncompleteMetaGraphPaginationException::class); test('pages spread across several portfolios are all collected', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response([ 'data' => [['id' => 'biz_1'], ['id' => 'biz_2']], @@ -174,15 +205,14 @@ function managedPagesGraphApi(): string ], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect(collect($pages)->pluck('id')->all())->toBe(['page_1', 'page_2']); + expect(managedPagesIds($walk))->toBe(['page_1', 'page_2']) + ->and($walk->complete)->toBeTrue(); }); test('a paginated portfolio edge is followed to the end', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::sequence() @@ -196,107 +226,90 @@ function managedPagesGraphApi(): string "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect(collect($pages)->pluck('id')->all())->toBe(['page_1', 'page_2']); + expect(managedPagesIds($walk))->toBe(['page_1', 'page_2']) + ->and($walk->complete)->toBeTrue(); }); -test('a portfolio entry without an id is skipped', function () { +test('a cursor that fails after the first page keeps that page and admits it is incomplete', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response([ - 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], - ], 200), - "{$graphApi}/me/businesses*" => Http::response(['data' => [['name' => 'No Id']]], 200), + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::sequence() + ->push([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor1"], + ], 200) + ->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(1); - Http::assertNotSent(fn ($request) => str_contains($request->url(), 'owned_pages')); + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); }); -test('a throttled portfolio edge is raised rather than read as no pages', function () { +test('a portfolio entry without an id is skipped', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), - "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), - "{$graphApi}/biz_1/owned_pages*" => Http::response([ - 'error' => ['message' => 'Application request limit reached', 'code' => 4], - ], 400), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['name' => 'No Id']]], 200), ]); - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); -})->throws(IncompleteMetaGraphPaginationException::class); - -test('an upstream failure listing portfolios is raised rather than read as no portfolios', function () { - $graphApi = managedPagesGraphApi(); - - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response([ - 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], - ], 200), - "{$graphApi}/me/businesses*" => Http::response(['error' => ['message' => 'oops']], 500), - ]); + expect($walk->pages)->toHaveCount(1) + ->and($walk->complete)->toBeTrue(); - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); -})->throws(IncompleteMetaGraphPaginationException::class); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'owned_pages')); +}); -test('a portfolio edge denied by permissions reads as no pages', function () { +test('more portfolios than the walk reads is an incomplete walk, not a failed one', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + $walk = managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response([ 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], ], 200), "{$graphApi}/me/businesses*" => Http::response([ - 'error' => ['message' => 'Requires business_management permission', 'code' => 200], - ], 403), + 'data' => [['id' => 'biz_1']], + 'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"], + ], 200), + "{$graphApi}/biz_1/*_pages*" => Http::response(['data' => []], 200), ]); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(1) - ->and(data_get($pages, '0.id'))->toBe('page_1'); + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); }); -test('the portfolio walk refuses to hand back a list it could not finish', function () { +test('the portfolio list is read in one request, never paginated', function () { $graphApi = managedPagesGraphApi(); - $portfolios = collect(range(1, ManagedPages::MAX_PORTFOLIOS + 1)) - ->map(fn (int $n) => ['id' => "biz_{$n}"]) - ->all(); - - Log::shouldReceive('error') - ->once() - ->with('Meta portfolio walk stopped: ceiling reached', [ - 'found' => ManagedPages::MAX_PORTFOLIOS + 1, - 'ceiling' => ManagedPages::MAX_PORTFOLIOS, - ]); - - Http::fake([ + + managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'data' => [['id' => 'biz_1']], + 'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"], + ], 200), + "{$graphApi}/biz_1/*_pages*" => Http::response(['data' => []], 200), ]); - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); -})->throws(IncompleteMetaGraphPaginationException::class); + expect(collect(Http::recorded())->filter( + fn (array $pair) => str_contains($pair[0]->url(), '/me/businesses'), + ))->toHaveCount(1); +}); test('portfolio edges are read concurrently rather than one after another', function () { $graphApi = managedPagesGraphApi(); $portfolios = collect(range(1, 30))->map(fn (int $n) => ['id' => "biz_{$n}"])->all(); - Http::fake([ + managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), "{$graphApi}/*_pages*" => Http::response(['data' => []], 200), ]); - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - Http::assertSentCount(2 + (30 * 2)); }); @@ -316,19 +329,17 @@ function managedPagesGraphApi(): string $fakes["{$graphApi}/biz_{$n}/client_pages*"] = Http::response(['data' => []], 200); } - Http::fake($fakes); + $walk = managedPagesWalk($fakes); - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - - expect($pages)->toHaveCount(26) - ->and(collect($pages)->pluck('id')->sort()->values()->all()) + expect($walk->pages)->toHaveCount(26) + ->and(collect($walk->pages)->pluck('id')->sort()->values()->all()) ->toBe(collect(range(1, 26))->map(fn (int $n) => "page_{$n}")->sort()->values()->all()); }); test('a portfolio edge paging off-host never gets the token', function () { $graphApi = managedPagesGraphApi(); - Http::fake([ + managedPagesWalk([ "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), "{$graphApi}/biz_1/owned_pages*" => Http::response([ @@ -338,61 +349,5 @@ function managedPagesGraphApi(): string "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), ]); - try { - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); - } catch (IncompleteMetaGraphPaginationException) { - // The off-host walk aborts; what matters is where the token did not go. - } - Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example')); }); - -test('a cursor that fails after the first page is raised, not read as the whole edge', function () { - $graphApi = managedPagesGraphApi(); - - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), - "{$graphApi}/biz_1/owned_pages*" => Http::sequence() - ->push([ - 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], - 'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor1"], - ], 200) - ->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400), - "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), - ]); - - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); -})->throws(IncompleteMetaGraphPaginationException::class); - -test('a portfolio list cut short mid-walk never silently drops the portfolios it did read', function () { - $graphApi = managedPagesGraphApi(); - - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), - "{$graphApi}/me/businesses*" => Http::sequence() - ->push([ - 'data' => [['id' => 'biz_1']], - 'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"], - ], 200) - ->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400), - ]); - - ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS); -})->throws(IncompleteMetaGraphPaginationException::class); - -test('a login meta reports as refusing business_management never touches the portfolio edges', function () { - $graphApi = managedPagesGraphApi(); - - Http::fake([ - "{$graphApi}/me/accounts*" => Http::response([ - 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], - ], 200), - ]); - - $pages = ManagedPages::forUser($graphApi, 'user-token', MANAGED_PAGES_FIELDS, ['pages_show_list']); - - expect($pages)->toHaveCount(1); - Http::assertSentCount(1); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); -}); From ece5f8e7730242e686ec225f5a364db9c23d5385 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 23:35:54 -0300 Subject: [PATCH 17/29] fix: an incomplete walk must not answer as if it were sure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking the walk incomplete stopped the auto-connect, but every dead end after it still gave a definitive answer. A login whose only Pages sit behind a throttled portfolio was told "no Facebook Pages found, you need to be an admin of at least one" — the exact sentence this branch exists to stop showing to admins, now arriving for a different reason. The already-connected and missing-permission answers were equally sure of themselves. When the walk could not see everything and there is nothing to offer, it says so and asks for a retry. --- .../Controllers/Auth/FacebookController.php | 12 ++- .../Auth/InstagramFacebookController.php | 12 ++- lang/ar/accounts.php | 1 + lang/de/accounts.php | 1 + lang/el/accounts.php | 1 + lang/en/accounts.php | 1 + lang/es/accounts.php | 1 + lang/fr/accounts.php | 1 + lang/it/accounts.php | 1 + lang/ja/accounts.php | 1 + lang/ko/accounts.php | 1 + lang/nl/accounts.php | 1 + lang/pl/accounts.php | 1 + lang/pt-BR/accounts.php | 1 + lang/ru/accounts.php | 1 + lang/tr/accounts.php | 1 + lang/uk/accounts.php | 1 + lang/zh/accounts.php | 1 + .../Feature/Social/FacebookControllerTest.php | 77 +++++++++++++++++++ 19 files changed, 109 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 8c5208447..8a98c69ed 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -91,15 +91,19 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $pages = ManagedPages::publishable($listed); if (empty($pages)) { - return $this->popupCallback(false, __(empty($listed) - ? 'accounts.popup_callback.no_facebook_pages' - : 'accounts.popup_callback.pages_missing_permission'), $this->platform->value); + return $this->popupCallback(false, __(match (true) { + ! $walk->complete => 'accounts.popup_callback.pages_read_incomplete', + empty($listed) => 'accounts.popup_callback.no_facebook_pages', + default => 'accounts.popup_callback.pages_missing_permission', + }), $this->platform->value); } $pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect); if (empty($pages)) { - return $this->noConnectableIdentities($reconnect, 'page_not_found'); + return $walk->complete + ? $this->noConnectableIdentities($reconnect, 'page_not_found') + : $this->popupCallback(false, __('accounts.popup_callback.pages_read_incomplete'), $this->platform->value); } // A lone page is only safe to take without asking when the walk saw everything diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 4a80fdce5..b86d850c2 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -111,9 +111,11 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $publishable = ManagedPages::publishable($listed); if (empty($publishable)) { - return $this->popupCallback(false, __(empty($listed) - ? 'accounts.popup_callback.no_facebook_instagram_pages' - : 'accounts.popup_callback.pages_missing_permission'), $this->platform->value); + return $this->popupCallback(false, __(match (true) { + ! $walk->complete => 'accounts.popup_callback.pages_read_incomplete', + empty($listed) => 'accounts.popup_callback.no_facebook_instagram_pages', + default => 'accounts.popup_callback.pages_missing_permission', + }), $this->platform->value); } $connectable = $this->filterConnectableIdentities( @@ -124,7 +126,9 @@ public function callback(Request $request): InertiaResponse|RedirectResponse ); if (empty($connectable)) { - return $this->noConnectableIdentities($existingAccount, 'page_not_found'); + return $walk->complete + ? $this->noConnectableIdentities($existingAccount, 'page_not_found') + : $this->popupCallback(false, __('accounts.popup_callback.pages_read_incomplete'), $this->platform->value); } $pages = $this->describeInstagramAccounts($connectable); diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 3c24a458c..c6a7101f9 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'فشل جلب الملف الشخصي.', 'page_not_found' => 'لم يتم العثور على الصفحة.', 'channel_not_found' => 'لم يتم العثور على القناة.', + 'pages_read_incomplete' => 'لم نتمكن من إكمال قراءة صفحاتك. حاول مرة أخرى بعد قليل.', 'pages_missing_permission' => 'وجدنا صفحاتك ولكن ليس إذن النشر فيها. أعد الاتصال واقبل جميع الأذونات.', 'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.', 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index c518b4583..ccc0ce02a 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -157,6 +157,7 @@ 'failed_to_get_profile' => 'Profil konnte nicht abgerufen werden.', 'page_not_found' => 'Seite nicht gefunden.', 'channel_not_found' => 'Kanal nicht gefunden.', + 'pages_read_incomplete' => 'Wir konnten deine Seiten nicht vollständig lesen. Bitte versuche es gleich noch einmal.', 'pages_missing_permission' => 'Wir haben deine Seiten gefunden, aber nicht die Berechtigung, dort zu posten. Verbinde erneut und akzeptiere alle Berechtigungen.', 'no_facebook_pages' => 'Keine Facebook-Seiten gefunden. Du musst Administrator mindestens einer Seite sein.', 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 84c932fc9..4ac117733 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Η ανάκτηση του προφίλ απέτυχε.', 'page_not_found' => 'Η σελίδα δεν βρέθηκε.', 'channel_not_found' => 'Το κανάλι δεν βρέθηκε.', + 'pages_read_incomplete' => 'Δεν μπορέσαμε να διαβάσουμε όλες τις Σελίδες σας. Δοκιμάστε ξανά σε λίγο.', 'pages_missing_permission' => 'Βρήκαμε τις Σελίδες σας, αλλά όχι την άδεια δημοσίευσης σε αυτές. Συνδεθείτε ξανά και αποδεχτείτε όλες τις άδειες.', 'no_facebook_pages' => 'Δεν βρέθηκαν σελίδες Facebook. Πρέπει να είστε διαχειριστής τουλάχιστον μίας σελίδας.', 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 70e361aa1..b722d284d 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Failed to get profile.', 'page_not_found' => 'Page not found.', 'channel_not_found' => 'Channel not found.', + 'pages_read_incomplete' => 'We could not finish reading your Pages. Please try again in a moment.', 'pages_missing_permission' => 'We found your Pages but not the permission to post to them. Reconnect and accept every permission.', 'no_facebook_pages' => 'No Facebook Pages found. You need to be an admin of at least one page.', 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index e066ed42b..f6462ca50 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Falló al obtener el perfil.', 'page_not_found' => 'Página no encontrada.', 'channel_not_found' => 'Canal no encontrado.', + 'pages_read_incomplete' => 'No pudimos terminar de leer tus páginas. Inténtalo de nuevo en un momento.', 'pages_missing_permission' => 'Encontramos tus páginas, pero no el permiso para publicar en ellas. Vuelve a conectar y acepta todos los permisos.', 'no_facebook_pages' => 'No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.', 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 81a9f2377..a58d4edb5 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Impossible de récupérer le profil.', 'page_not_found' => 'Page introuvable.', 'channel_not_found' => 'Chaîne introuvable.', + 'pages_read_incomplete' => 'Nous n’avons pas pu finir de lire vos Pages. Réessayez dans un instant.', 'pages_missing_permission' => 'Nous avons trouvé vos Pages, mais pas l’autorisation d’y publier. Reconnectez-vous en acceptant toutes les autorisations.', 'no_facebook_pages' => 'Aucune page Facebook trouvée. Vous devez être administrateur d\'au moins une page.', 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 8a6e0d71f..d56a22876 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Impossibile ottenere il profilo.', 'page_not_found' => 'Pagina non trovata.', 'channel_not_found' => 'Canale non trovato.', + 'pages_read_incomplete' => 'Non siamo riusciti a leggere tutte le tue Pagine. Riprova tra poco.', 'pages_missing_permission' => 'Abbiamo trovato le tue Pagine, ma non l’autorizzazione a pubblicarci. Riconnetti accettando tutte le autorizzazioni.', 'no_facebook_pages' => 'Nessuna pagina Facebook trovata. Devi essere amministratore di almeno una pagina.', 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index ae58d2f1e..3c1b72c69 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'プロフィールの取得に失敗しました。', 'page_not_found' => 'ページが見つかりません。', 'channel_not_found' => 'チャンネルが見つかりません。', + 'pages_read_incomplete' => 'ページをすべて読み取れませんでした。少し時間をおいて再度お試しください。', 'pages_missing_permission' => 'ページは見つかりましたが、投稿する権限がありません。再接続してすべての権限を許可してください。', 'no_facebook_pages' => 'Facebook ページが見つかりません。少なくとも 1 つのページの管理者である必要があります。', 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 0234be4fa..87a692e44 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => '프로필을 가져오지 못했습니다.', 'page_not_found' => '페이지를 찾을 수 없습니다.', 'channel_not_found' => '채널을 찾을 수 없습니다.', + 'pages_read_incomplete' => '페이지를 모두 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.', 'pages_missing_permission' => '페이지는 찾았지만 게시 권한이 없습니다. 다시 연결하고 모든 권한을 허용해 주세요.', 'no_facebook_pages' => 'Facebook 페이지를 찾을 수 없습니다. 최소 한 개 페이지의 관리자여야 합니다.', 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index ccc420a35..71cbb00a0 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Kon profiel niet ophalen.', 'page_not_found' => 'Pagina niet gevonden.', 'channel_not_found' => 'Kanaal niet gevonden.', + 'pages_read_incomplete' => 'We konden je pagina’s niet volledig uitlezen. Probeer het zo meteen opnieuw.', 'pages_missing_permission' => 'We hebben je pagina’s gevonden, maar niet de rechten om erop te posten. Maak opnieuw verbinding en accepteer alle rechten.', 'no_facebook_pages' => 'Geen Facebook-pagina\'s gevonden. Je moet beheerder zijn van ten minste één pagina.', 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 3d4833ab6..024554ed5 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Nie udało się pobrać profilu.', 'page_not_found' => 'Nie znaleziono strony.', 'channel_not_found' => 'Nie znaleziono kanału.', + 'pages_read_incomplete' => 'Nie udało się odczytać wszystkich Twoich stron. Spróbuj ponownie za chwilę.', 'pages_missing_permission' => 'Znaleźliśmy Twoje strony, ale nie uprawnienia do publikowania na nich. Połącz ponownie i zaakceptuj wszystkie uprawnienia.', 'no_facebook_pages' => 'Nie znaleziono stron na Facebooku. Musisz być administratorem co najmniej jednej strony.', 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index ccf75e590..a85026697 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Falha ao obter perfil.', 'page_not_found' => 'Página não encontrada.', 'channel_not_found' => 'Canal não encontrado.', + 'pages_read_incomplete' => 'Não conseguimos terminar de ler suas páginas. Tente novamente em instantes.', 'pages_missing_permission' => 'Encontramos suas páginas, mas não a permissão para publicar nelas. Reconecte aceitando todas as permissões.', 'no_facebook_pages' => 'Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.', 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 00645921e..c925777d1 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Не удалось получить профиль.', 'page_not_found' => 'Страница не найдена.', 'channel_not_found' => 'Канал не найден.', + 'pages_read_incomplete' => 'Не удалось прочитать все ваши страницы. Попробуйте ещё раз через минуту.', 'pages_missing_permission' => 'Мы нашли ваши страницы, но не разрешение публиковать на них. Подключитесь заново и примите все разрешения.', 'no_facebook_pages' => 'Страницы Facebook не найдены. Вы должны быть администратором хотя бы одной страницы.', 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 21905a21b..833af6847 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -157,6 +157,7 @@ 'failed_to_get_profile' => 'Profil alınamadı.', 'page_not_found' => 'Sayfa bulunamadı.', 'channel_not_found' => 'Kanal bulunamadı.', + 'pages_read_incomplete' => 'Sayfalarınızın tamamını okuyamadık. Birazdan tekrar deneyin.', 'pages_missing_permission' => 'Sayfalarınızı bulduk ama orada paylaşım izni bulamadık. Yeniden bağlanıp tüm izinleri kabul edin.', 'no_facebook_pages' => 'Facebook Sayfası bulunamadı. En az bir sayfanın yöneticisi olmanız gerekir.', 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index a4c555cb5..af1a824ba 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => 'Не вдалося отримати профіль.', 'page_not_found' => 'Сторінку не знайдено.', 'channel_not_found' => 'Канал не знайдено.', + 'pages_read_incomplete' => 'Не вдалося прочитати всі ваші сторінки. Спробуйте ще раз за хвилину.', 'pages_missing_permission' => 'Ми знайшли ваші сторінки, але не дозвіл публікувати на них. Підключіться знову та надайте всі дозволи.', 'no_facebook_pages' => 'Сторінок Facebook не знайдено. Ви маєте бути адміністратором хоча б однієї сторінки.', 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 737eb20fd..edd675d5f 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -155,6 +155,7 @@ 'failed_to_get_profile' => '获取主页信息失败。', 'page_not_found' => '未找到页面。', 'channel_not_found' => '未找到频道。', + 'pages_read_incomplete' => '我们没能读取你的全部主页。请稍后再试。', 'pages_missing_permission' => '我们找到了你的主页,但没有发布权限。请重新连接并接受所有权限。', 'no_facebook_pages' => '未找到 Facebook 主页。你至少需要是一个主页的管理员。', 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index 15d80d522..35c0c829a 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -1441,3 +1441,80 @@ $this->assertDatabaseCount('social_accounts', 1); }); + +test('facebook says the walk was cut short rather than claiming there are no pages', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'business_management', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'code' => 4], + ], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.pages_read_incomplete'))); +}); + +test('facebook says the walk was cut short rather than claiming everything is connected', function () { + config()->set('trypost.allow_multiple_social_accounts', true); + + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Facebook, + 'platform_user_id' => 'page_taken', + ]); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'business_management', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_taken', + 'name' => 'Already Connected', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'busy', 'code' => 2]], 500), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.pages_read_incomplete'))); +}); From 450f5702419665123e3eb1fe99e835a5039e6896 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 23:41:43 -0300 Subject: [PATCH 18/29] fix: stop every Inertia test from calling an SSR server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned it off, so every test rendering an Inertia page issued a real request to the SSR endpoint. The project does not use SSR, so those calls only ever failed and fell back to client rendering — quietly, on every run. Defaulting it off is what the project already assumed, and it retires the allowStrayRequests hole the Meta connect tests were carrying to work around it. INERTIA_SSR_ENABLED still turns it back on. --- config/inertia.php | 2 +- tests/Feature/Social/FacebookControllerTest.php | 1 - tests/Feature/Social/InstagramFacebookControllerTest.php | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/config/inertia.php b/config/inertia.php index 4da733d86..002954c1d 100644 --- a/config/inertia.php +++ b/config/inertia.php @@ -23,7 +23,7 @@ 'ssr' => [ - 'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), + 'enabled' => (bool) env('INERTIA_SSR_ENABLED', false), 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index 35c0c829a..a93dbf03a 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -16,7 +16,6 @@ beforeEach(function () { Http::preventStrayRequests(); - Http::allowStrayRequests(['*__inertia_ssr*']); $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index f984e029c..a0ad869f2 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -15,7 +15,6 @@ beforeEach(function () { Http::preventStrayRequests(); - Http::allowStrayRequests(['*__inertia_ssr*']); $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); From 65684c8a483b6ab73311b8951bac626a7239d02a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 23:46:24 -0300 Subject: [PATCH 19/29] fix: a taken slot is a fact, not a guess about the listing Routing every short listing to "try again in a moment" swallowed network_taken: a workspace that already holds its one Facebook account was told to retry, forever, whenever a portfolio edge was throttled. That answer comes from our own rows and does not depend on how far the walk got. all_connected and page_not_found do, and still yield. Also: an off-host cursor now stops the edge instead of re-reading page one, which cost a request and could follow an on-host cursor on the retry, quietly undoing the guard. Cursor follow-ups are budgeted, since they cannot be pooled and were the one unbounded serial path left. The exception's fetched count lost its last reader two commits ago and is gone. Comments trimmed throughout. --- ...IncompleteMetaGraphPaginationException.php | 24 +-- .../Controllers/Auth/FacebookController.php | 5 +- .../Auth/InstagramFacebookController.php | 5 +- .../Controllers/Auth/SocialController.php | 11 +- .../Social/Meta/GrantedPermissions.php | 18 +- app/Services/Social/Meta/GraphPaginator.php | 21 +- app/Services/Social/Meta/ManagedPageList.php | 9 +- app/Services/Social/Meta/ManagedPages.php | 187 +++++++++--------- .../Feature/Social/FacebookControllerTest.php | 42 ++++ tests/Unit/Social/Meta/ManagedPagesTest.php | 41 ++++ 10 files changed, 202 insertions(+), 161 deletions(-) diff --git a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php index 0165e518c..8f070e73d 100644 --- a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php +++ b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php @@ -8,28 +8,16 @@ use Throwable; /** - * Thrown when a Meta Graph edge could not be fully fetched — the first page - * failed, a later page failed, or pagination stopped pathologically. Callers - * must not treat this as an empty or complete list (e.g. "no pages" or - * auto-connect when count === 1). + * A Meta Graph edge could not be fully fetched. Callers must not read this as an + * empty or complete list. * - * `$transient` separates a throttle, an upstream hiccup or a truncated walk — - * where the real list is unknown — from a confirmed rejection such as a denied - * permission, where Meta has told us this login reaches nothing on that edge. - * Only the latter is safe for a caller to read as an empty list; anything - * unknown defaults to transient. - * - * `$fetched` counts the pages that did arrive. A rejection on the very first - * request means the edge was never readable; a rejection after that means a walk - * that had started got cut short, and what arrived is a fragment either way. + * `$transient` separates a throttle or an upstream hiccup, where the real list is + * unknown, from a confirmed rejection, where Meta has answered. Unknown by default. */ class IncompleteMetaGraphPaginationException extends RuntimeException { - public function __construct( - ?Throwable $previous = null, - public readonly bool $transient = true, - public readonly int $fetched = 0, - ) { + public function __construct(?Throwable $previous = null, public readonly bool $transient = true) + { parent::__construct('Meta Graph pagination did not complete.', previous: $previous); } } diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 8a98c69ed..1e3b7257e 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -101,12 +101,9 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect); if (empty($pages)) { - return $walk->complete - ? $this->noConnectableIdentities($reconnect, 'page_not_found') - : $this->popupCallback(false, __('accounts.popup_callback.pages_read_incomplete'), $this->platform->value); + return $this->noConnectableIdentities($reconnect, 'page_not_found', $walk->complete); } - // A lone page is only safe to take without asking when the walk saw everything if (count($pages) === 1 && ($walk->complete || $reconnect !== null)) { $page = $pages[0]; $avatarPath = uploadFromUrl(data_get($page, 'picture')); diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index b86d850c2..015da32d4 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -126,14 +126,11 @@ public function callback(Request $request): InertiaResponse|RedirectResponse ); if (empty($connectable)) { - return $walk->complete - ? $this->noConnectableIdentities($existingAccount, 'page_not_found') - : $this->popupCallback(false, __('accounts.popup_callback.pages_read_incomplete'), $this->platform->value); + return $this->noConnectableIdentities($existingAccount, 'page_not_found', $walk->complete); } $pages = $this->describeInstagramAccounts($connectable); - // A lone page is only safe to take without asking when the walk saw everything if (count($pages) === 1 && ($walk->complete || $existingAccount !== null)) { return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount, $granted); } diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 375dae5ce..69fca3fb3 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -149,13 +149,16 @@ protected function reconnectAccount(Workspace $workspace, mixed $reconnectId = n * Nothing on this network is left to connect: the card being reconnected is * gone from the provider, this login has nothing left to offer, or the * single slot is taken. + * + * A taken slot is a fact about our own rows, so it stands even when the provider + * listing came back short. The other two answers depend on having seen everything. */ - protected function noConnectableIdentities(?SocialAccount $reconnect, string $missingKey): Response + protected function noConnectableIdentities(?SocialAccount $reconnect, string $missingKey, bool $listingComplete = true): Response { $key = match (true) { - $reconnect !== null => $missingKey, - (bool) config('trypost.allow_multiple_social_accounts') => 'all_connected', - default => 'network_taken', + ! (bool) config('trypost.allow_multiple_social_accounts') && $reconnect === null => 'network_taken', + $listingComplete => $reconnect !== null ? $missingKey : 'all_connected', + default => 'pages_read_incomplete', }; return $this->popupCallback(false, __("accounts.popup_callback.{$key}"), $this->platform->value); diff --git a/app/Services/Social/Meta/GrantedPermissions.php b/app/Services/Social/Meta/GrantedPermissions.php index ab729e2f4..58c60d0e3 100644 --- a/app/Services/Social/Meta/GrantedPermissions.php +++ b/app/Services/Social/Meta/GrantedPermissions.php @@ -10,23 +10,13 @@ /** * The scopes a Meta login is not known to have refused. * - * Meta lets someone decline individual permissions in the consent dialog, so the - * scope list an app asked for is a request, not a record. Storing it on the - * account claims access the login may have refused — `business_management` above - * all, which also needs Advanced Access and is declined by default without it. - * - * Only a scope Meta explicitly reports as declined or expired is dropped. A scope - * it does not mention is kept: `/me/permissions` is paginated and Meta does not - * document that it echoes scope strings verbatim, so an absence is unknown, not a - * refusal — and PublishToSocialPlatform::failForMissingScopes() blocks publishing - * on a scope missing from this column. Guessing there would turn a cosmetic - * inaccuracy into dead accounts. + * Only a scope Meta explicitly reports declined or expired is dropped. An absence is + * unknown, not a refusal — `/me/permissions` is paginated and Meta does not document + * that it echoes scope strings verbatim, and failForMissingScopes() blocks publishing + * on a scope missing from this column. */ class GrantedPermissions { - /** - * Statuses that mean this login will not act on the scope. - */ private const REFUSED = ['declined', 'expired']; /** diff --git a/app/Services/Social/Meta/GraphPaginator.php b/app/Services/Social/Meta/GraphPaginator.php index 02eb328e2..8328617e2 100644 --- a/app/Services/Social/Meta/GraphPaginator.php +++ b/app/Services/Social/Meta/GraphPaginator.php @@ -14,11 +14,10 @@ use Throwable; /** - * Collects every item from a paginated Meta Graph API edge by following `paging.next`. + * Collects every item from a paginated Meta Graph edge by following `paging.next`. * - * Stops only when pagination is exhausted. Request failures and pathological cases - * (repeated next URL, off-host next URL, extreme page count) throw so callers never - * confuse an error with an empty Page list or auto-connect on a truncated list. + * Failures and pathological cases (repeated next URL, off-host next URL, extreme page + * count) throw, so no caller confuses an error with an empty list. */ class GraphPaginator { @@ -83,11 +82,7 @@ public static function all(string $url, array $query = []): array return $items->values()->all(); } - /** - * Classify and log a single failed Graph response for a caller that read the - * page itself — a pooled first page — rather than walking it here. Keeps the - * one description of what a Graph failure means in this one place. - */ + /** Classify and log a failed response a caller read itself, rather than walked here. */ public static function failure(string $url, Response $response): IncompleteMetaGraphPaginationException { return self::describe($url, 0, response: $response); @@ -106,11 +101,7 @@ private static function abort( throw self::describe($url, $fetched, $e, $response, $reason); } - /** - * A confirmed rejection is Meta answering the question, and callers that treat - * it as "this edge is not readable" log nothing further — so it is a warning. - * Anything unknown leaves a walk in the dark and stays at error level. - */ + /** A confirmed rejection is Meta answering, so it warns; an unknown stays an error. */ private static function describe( string $url, int $fetched, @@ -131,6 +122,6 @@ private static function describe( $transient ? Log::error($message, $context) : Log::warning($message, $context); - return new IncompleteMetaGraphPaginationException($e, transient: $transient, fetched: $fetched); + return new IncompleteMetaGraphPaginationException($e, transient: $transient); } } diff --git a/app/Services/Social/Meta/ManagedPageList.php b/app/Services/Social/Meta/ManagedPageList.php index 3c4a17b45..b85699d62 100644 --- a/app/Services/Social/Meta/ManagedPageList.php +++ b/app/Services/Social/Meta/ManagedPageList.php @@ -4,14 +4,7 @@ namespace App\Services\Social\Meta; -/** - * What a page walk found, and whether it found everything. - * - * The portfolio edges are additive: failing to read them must not deny a login the - * Pages `/me/accounts` already returned. But a caller that auto-connects a lone - * Page would then be binding a workspace off a list it cannot vouch for, so an - * incomplete walk is carried alongside the Pages rather than swallowed. - */ +/** What a page walk found, and whether it found everything. */ final readonly class ManagedPageList { /** diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 9c6641cb4..5f5a36850 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -13,46 +13,38 @@ use Illuminate\Support\Uri; /** - * Every Facebook Page a login can publish to, gathered from all the edges Meta lists them under. + * Every Facebook Page a login can publish to. * - * `/me/accounts` only returns Pages the person holds a Page role on. Someone whose - * access comes from a Business Portfolio assignment — an admin of a Page owned by - * someone else's portfolio, the norm under the New Pages Experience — gets an empty - * list there, so the portfolio's own `owned_pages` and `client_pages` edges are read - * too and merged by Page id. + * `/me/accounts` only returns Pages the person holds a Page role on, so a Business + * Portfolio admin — the New Pages Experience norm — gets nothing there. The + * portfolio's `owned_pages` and `client_pages` edges are read too and merged by id. * - * Those edges need `business_management`. A login Meta reports as having refused it - * skips them outright — walking anyway would spend a request on a certain 403 and - * log it at error level on every otherwise-successful connect. A refusal Meta does - * not report is not assumed: the walk runs, and a rejection there still reads as - * "this login reaches no portfolio pages" rather than failing the connect. - * - * A throttle, an upstream hiccup or more portfolios than the ceiling walks leaves the - * real list unknown. None of that denies the connect — the Pages that did arrive are - * still returned — but the walk reports itself incomplete so no caller auto-connects - * off a list it cannot vouch for. Only `/me/accounts` itself failing is fatal: with - * nothing to stand on there is no list at all. + * Only `/me/accounts` failing is fatal. Anything else keeps what arrived and reports + * the walk incomplete, so no caller auto-connects off a list it cannot vouch for. */ class ManagedPages { private const PER_PAGE = 100; - /** - * Portfolio edges read concurrently per round. The walk sits inside a - * synchronous OAuth callback, where serial round trips are what break it. - */ private const EDGES_PER_ROUND = 20; - /** - * Portfolios walked, and the page size asked of `/me/businesses` — the walk - * reads that one page and no more, so this is what bounds the whole thing. - */ + private const PORTFOLIO_SCOPE = 'business_management'; + + /** Portfolios read, and the page size asked of `/me/businesses`, which is read once. */ public const MAX_PORTFOLIOS = 100; - /** - * The permission Meta requires to read a portfolio's Page edges. - */ - private const PORTFOLIO_SCOPE = 'business_management'; + /** Cursor follow-ups allowed across the whole walk; these cannot be pooled. */ + public const MAX_CONTINUATIONS = 50; + + private bool $complete = true; + + private int $continuations = 0; + + private function __construct( + private readonly string $graphApi, + private readonly string $userToken, + private readonly string $fields, + ) {} /** * @param array $grantedScopes @@ -65,23 +57,11 @@ public static function forUser( string $fields, array $grantedScopes = [self::PORTFOLIO_SCOPE], ): ManagedPageList { - $query = ['access_token' => $userToken, 'fields' => $fields, 'limit' => self::PER_PAGE]; - $pages = collect(GraphPaginator::all("{$graphApi}/me/accounts", $query)); - - if (! in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true)) { - return new ManagedPageList(self::merge($pages), true); - } - - $complete = true; - $pages = $pages->concat(self::portfolioPages($graphApi, $userToken, $query, $complete)); - - return new ManagedPageList(self::merge($pages), $complete); + return (new self($graphApi, $userToken, $fields))->walk($grantedScopes); } /** - * The Pages this login can actually post to. A Page Meta lists without an - * `access_token` — the shape of a login that declined `pages_read_engagement` - * on Meta's per-permission toggles — would connect into an account that + * A Page Meta lists without an `access_token` would connect into an account that * cannot publish, so callers separate it from a Page they never had. * * @param array> $pages @@ -96,50 +76,50 @@ public static function publishable(array $pages): array } /** - * One record per Page id, preferring whichever copy carries a token. - * - * @param Collection> $pages - * @return list> + * @param array $grantedScopes */ - private static function merge(Collection $pages): array + private function walk(array $grantedScopes): ManagedPageList { - return $pages - ->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1) - ->unique(fn (array $page) => (string) data_get($page, 'id')) - ->values() - ->all(); + $pages = collect(GraphPaginator::all("{$this->graphApi}/me/accounts", $this->query())); + + if (in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true)) { + $pages = $pages->concat($this->portfolioPages()); + } + + return new ManagedPageList( + $pages + ->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1) + ->unique(fn (array $page) => (string) data_get($page, 'id')) + ->values() + ->all(), + $this->complete, + ); } /** - * @param array $query * @return Collection> */ - private static function portfolioPages(string $graphApi, string $userToken, array $query, bool &$complete): Collection + private function portfolioPages(): Collection { - $rounds = collect(self::businessIds($graphApi, $userToken, $complete)) + $rounds = collect($this->businessIds()) ->crossJoin(['owned_pages', 'client_pages']) - ->map(fn (array $edge) => Uri::of("{$graphApi}/{$edge[0]}/{$edge[1]}")->withQuery($query)->value()) + ->map(fn (array $edge) => Uri::of("{$this->graphApi}/{$edge[0]}/{$edge[1]}")->withQuery($this->query())->value()) ->chunk(self::EDGES_PER_ROUND); $pages = collect(); foreach ($rounds as $round) { - $pages = $pages->concat(self::readRound($round, $complete)); + $pages = $pages->concat($this->readRound($round)); } return $pages; } /** - * Reads a round of edges at once, classifying each answer where it lands so a - * failure costs one request rather than two. A `paging.next` is followed only - * when it stays on the host the edge was read from; anything else is handed to - * GraphPaginator, whose own guard refuses it. - * * @param Collection $urls * @return Collection> */ - private static function readRound(Collection $urls, bool &$complete): Collection + private function readRound(Collection $urls): Collection { $urls = $urls->values(); @@ -153,83 +133,83 @@ private static function readRound(Collection $urls, bool &$complete): Collection $response = data_get($responses, $index); if (! $response instanceof Response) { - $complete = false; + $this->complete = false; continue; } if ($response->failed()) { - $complete = GraphPaginator::failure($url, $response)->transient ? false : $complete; - - continue; - } - - $pages = $pages->concat($response->collect('data')); - $next = $response->json('paging.next'); + $this->note($url, $response); - if (! is_string($next) || blank($next)) { continue; } - $pages = $pages->concat(self::rest( - Uri::of($next)->host() === Uri::of($url)->host() ? $next : $url, - $complete, - )); + $pages = $pages->concat($response->collect('data'))->concat( + $this->rest($url, $response->json('paging.next')), + ); } return $pages; } /** - * Follows what is left of an edge. Anything short of the whole remainder — a - * rejection included, since a page already arrived — leaves the walk unable to - * vouch for the edge. + * Follows what is left of an edge. A cursor cannot be pooled, so the budget is + * what keeps a synchronous OAuth callback from walking thousands of pages. * * @return list> */ - private static function rest(string $url, bool &$complete): array + private function rest(string $url, mixed $next): array { + if (! is_string($next) || blank($next)) { + return []; + } + + if (Uri::of($next)->host() !== Uri::of($url)->host() || $this->continuations >= self::MAX_CONTINUATIONS) { + $this->complete = false; + + return []; + } + + $this->continuations++; + try { - return GraphPaginator::all($url); + return GraphPaginator::all($next); } catch (IncompleteMetaGraphPaginationException) { - $complete = false; + $this->complete = false; return []; } } /** - * The portfolios to walk, from a single request. Reading only the first page - * is what actually bounds the work: paginating here would let one login spawn - * thousands of edge reads inside a synchronous OAuth callback. More portfolios - * than fit means the walk cannot see all of them, which is an incomplete walk, - * not a failed one. + * Reading one page is what bounds the walk: paginating here would let one login + * spawn thousands of edge reads. More portfolios than fit is incomplete, not failed. * * @return list */ - private static function businessIds(string $graphApi, string $userToken, bool &$complete): array + private function businessIds(): array { - $url = "{$graphApi}/me/businesses"; + $url = "{$this->graphApi}/me/businesses"; try { $response = Http::timeout(15)->connectTimeout(5)->get($url, [ - 'access_token' => $userToken, + 'access_token' => $this->userToken, 'limit' => self::MAX_PORTFOLIOS, ]); } catch (ConnectionException) { - $complete = false; + $this->complete = false; return []; } if ($response->failed()) { - $complete = GraphPaginator::failure($url, $response)->transient ? false : $complete; + $this->note($url, $response); return []; } if (filled($response->json('paging.next'))) { - $complete = false; + $this->complete = false; } return $response->collect('data') @@ -240,4 +220,23 @@ private static function businessIds(string $graphApi, string $userToken, bool &$ ->values() ->all(); } + + /** + * A rejection is Meta answering that this login reaches nothing there. Anything + * else leaves the edge unread, which the walk cannot vouch for. + */ + private function note(string $url, Response $response): void + { + if (GraphPaginator::failure($url, $response)->transient) { + $this->complete = false; + } + } + + /** + * @return array + */ + private function query(): array + { + return ['access_token' => $this->userToken, 'fields' => $this->fields, 'limit' => self::PER_PAGE]; + } } diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index a93dbf03a..e24becc80 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -1517,3 +1517,45 @@ ->where('success', false) ->where('message', __('accounts.popup_callback.pages_read_incomplete'))); }); + +test('facebook still says the slot is taken when the walk came back short', function () { + SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Facebook, + 'platform_user_id' => 'page_taken', + ]); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); + + $graphApi = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [ + ['permission' => 'business_management', 'status' => 'granted'], + ]], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_taken', + 'name' => 'Already Connected', + 'picture' => ['data' => ['url' => null]], + 'access_token' => 'page-token', + ]]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'busy', 'code' => 2]], 500), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + $this->actingAs($this->user) + ->get(route('app.social.facebook.callback')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('success', false) + ->where('message', __('accounts.popup_callback.network_taken'))); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 1612ff91f..177b8dd77 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -351,3 +351,44 @@ function managedPagesIds(ManagedPageList $walk): array Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example')); }); + +test('an off-host cursor stops the edge instead of re-reading it', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => 'https://evil.example/owned_pages?access_token=user-token'], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example')); + expect(collect(Http::recorded())->filter( + fn (array $pair) => str_contains($pair[0]->url(), 'owned_pages'), + ))->toHaveCount(1); +}); + +test('the cursor budget stops a walk that would never end', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'data' => collect(range(1, ManagedPages::MAX_CONTINUATIONS + 5)) + ->map(fn (int $n) => ['id' => "biz_{$n}"]) + ->all(), + ], 200), + "{$graphApi}/*_pages*" => Http::response([ + 'data' => [['id' => 'page_x', 'name' => 'X', 'access_token' => 'token']], + 'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor"], + ], 200), + ]); + + expect($walk->complete)->toBeFalse(); +}); From caf727d7b46a9635d33034639af0371350ca227a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 25 Aug 2026 23:57:43 -0300 Subject: [PATCH 20/29] fix: bound the cursor walk by requests, and keep what it read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAX_CONTINUATIONS counted edges, not requests: each one then handed off to GraphPaginator, which follows up to a hundred more pages by itself. The budget the docblock promised was fifty times larger than it claimed. Cursors are now followed one budgeted request at a time, so the count means what it says, and pages already read survive a cut-off instead of being thrown away with the exception. A refused /me/businesses is no longer read as "this login has no portfolios". For a single edge a rejection answers the question; for the index of edges it means we could not look — and answering complete there auto-connected the one /me/accounts page while hiding every portfolio Page, which is this branch's own bug wearing a different hat. SSR goes back to its shipped default. Turning it off in config to quiet the test suite would have disabled it wherever it is actually started — docker/Dockerfile builds the bundle. phpunit.xml carries the switch now, next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why. --- CLAUDE.md | 6 ++ app/Services/Social/Meta/ManagedPages.php | 53 +++++++++++------ config/inertia.php | 2 +- phpunit.xml | 1 + tests/Unit/Social/Meta/ManagedPagesTest.php | 64 ++++++++++++++++++++- 5 files changed, 106 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4add397e4..baa9d28d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,12 @@ Vue components must have a single root element. - Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations. +## Inertia SSR + +- This project does **not** run Inertia SSR. Nothing in the repo sets `INERTIA_SSR_ENABLED`, and no process starts `inertia:start-ssr`. +- Tests disable it in `phpunit.xml` (`INERTIA_SSR_ENABLED=false`), the same way `PULSE_ENABLED` / `TELESCOPE_ENABLED` / `NIGHTWATCH_ENABLED` are handled. Without it, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering. +- Do **not** fix that by flipping the default in `config/inertia.php` — the SSR build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`), so a changed default would silently turn SSR off wherever it is started for us. + ## Dialogs - In ``, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens. diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 5f5a36850..261d11c95 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -33,8 +33,8 @@ class ManagedPages /** Portfolios read, and the page size asked of `/me/businesses`, which is read once. */ public const MAX_PORTFOLIOS = 100; - /** Cursor follow-ups allowed across the whole walk; these cannot be pooled. */ - public const MAX_CONTINUATIONS = 50; + /** Cursor requests allowed across the whole walk; these cannot be pooled. */ + public const MAX_CONTINUATIONS = 25; private bool $complete = true; @@ -153,38 +153,54 @@ private function readRound(Collection $urls): Collection } /** - * Follows what is left of an edge. A cursor cannot be pooled, so the budget is - * what keeps a synchronous OAuth callback from walking thousands of pages. + * Follows what is left of an edge, one budgeted request at a time. A cursor cannot + * be pooled, so this is the only serial path in the walk. Whatever arrived before a + * cut-off is kept; only the walk's completeness is lost. * * @return list> */ private function rest(string $url, mixed $next): array { - if (! is_string($next) || blank($next)) { - return []; - } + $pages = []; - if (Uri::of($next)->host() !== Uri::of($url)->host() || $this->continuations >= self::MAX_CONTINUATIONS) { - $this->complete = false; + while (is_string($next) && filled($next)) { + if ($this->continuations >= self::MAX_CONTINUATIONS || Uri::of($next)->host() !== Uri::of($url)->host()) { + $this->complete = false; - return []; - } + break; + } - $this->continuations++; + $this->continuations++; - try { - return GraphPaginator::all($next); - } catch (IncompleteMetaGraphPaginationException) { - $this->complete = false; + try { + $response = Http::timeout(15)->connectTimeout(5)->get($next); + } catch (ConnectionException) { + $this->complete = false; - return []; + break; + } + + if ($response->failed()) { + GraphPaginator::failure($next, $response); + $this->complete = false; + + break; + } + + $pages = [...$pages, ...$response->collect('data')->all()]; + $next = $response->json('paging.next'); } + + return $pages; } /** * Reading one page is what bounds the walk: paginating here would let one login * spawn thousands of edge reads. More portfolios than fit is incomplete, not failed. * + * A refusal here is not "this login has no portfolios" — it is "we could not look", + * which is the difference between an edge and the index of edges. + * * @return list */ private function businessIds(): array @@ -203,7 +219,8 @@ private function businessIds(): array } if ($response->failed()) { - $this->note($url, $response); + GraphPaginator::failure($url, $response); + $this->complete = false; return []; } diff --git a/config/inertia.php b/config/inertia.php index 002954c1d..4da733d86 100644 --- a/config/inertia.php +++ b/config/inertia.php @@ -23,7 +23,7 @@ 'ssr' => [ - 'enabled' => (bool) env('INERTIA_SSR_ENABLED', false), + 'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), diff --git a/phpunit.xml b/phpunit.xml index 97f74bc87..89d137540 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,6 +33,7 @@ + diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 177b8dd77..cdd407b36 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -131,7 +131,7 @@ function managedPagesIds(ManagedPageList $walk): array Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); }); -test('a portfolio edge denied by permissions is a complete walk with nothing behind it', function () { +test('a refused portfolio index means we could not look, not that there is nothing', function () { $graphApi = managedPagesGraphApi(); $walk = managedPagesWalk([ @@ -143,10 +143,72 @@ function managedPagesIds(ManagedPageList $walk): array ], 403), ]); + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); + +test('a refused single edge is an answer about that edge, and leaves the walk complete', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Owned', 'access_token' => 'token-1']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response([ + 'error' => ['message' => 'permission denied', 'code' => 10], + ], 403), + ]); + expect(managedPagesIds($walk))->toBe(['page_1']) ->and($walk->complete)->toBeTrue(); }); +test('a continuation cut short keeps the pages it already read', function () { + $graphApi = managedPagesGraphApi(); + $cursor = "{$graphApi}/biz_1/owned_pages?access_token=user-token&after="; + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::sequence() + ->push([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => "{$cursor}c1"], + ], 200) + ->push([ + 'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']], + 'paging' => ['next' => "{$cursor}c2"], + ], 200) + ->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1', 'page_2']) + ->and($walk->complete)->toBeFalse(); +}); + +test('the cursor budget counts requests, not edges', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + "{$graphApi}/biz_1/owned_pages*" => Http::response([ + 'data' => [['id' => 'page_x', 'name' => 'X', 'access_token' => 'token']], + 'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=forever"], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect($walk->complete)->toBeFalse(); + + expect(collect(Http::recorded())->filter( + fn (array $pair) => str_contains($pair[0]->url(), 'after=forever'), + ))->toHaveCount(ManagedPages::MAX_CONTINUATIONS); +}); + test('a throttled portfolio edge keeps the pages it has and admits it is incomplete', function () { $graphApi = managedPagesGraphApi(); From 34278ca7c429373a6205745f5be1b36f65becca4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 00:01:03 -0300 Subject: [PATCH 21/29] refactor: one Meta connect flow instead of two kept in step by hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five lines: the profile touch Meta's review wants, the granted-scope read and the publish-scope refusal, the page walk, and the answer for a walk with nothing to offer. They only matched because both were edited side by side, every round, which is a guarantee nobody should be making by hand. graphApi() moves to SocialController and reads the host by platform value, so it serves every network rather than the two that had copied it, and graphVersion() derives from it instead of reading config a second time. select() stays as it is. The two differ in the middle — different identity keys, different connect shapes — and folding them would be abstraction for its own sake. --- .../Controllers/Auth/FacebookController.php | 44 ++++--------- .../Auth/InstagramFacebookController.php | 41 ++++--------- app/Http/Controllers/Auth/MetaController.php | 61 +++++++++++++++++++ .../Controllers/Auth/SocialController.php | 6 ++ 4 files changed, 89 insertions(+), 63 deletions(-) create mode 100644 app/Http/Controllers/Auth/MetaController.php diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index 1e3b7257e..e0435ccd4 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -9,12 +9,10 @@ use App\Exceptions\SocialAccount\ConnectPopupException; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; -use App\Services\Social\Meta\GrantedPermissions; use App\Services\Social\Meta\ManagedPages; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Uri; use Inertia\Inertia; @@ -22,13 +20,13 @@ use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; -class FacebookController extends SocialController +class FacebookController extends MetaController { - protected string $driver = 'facebook'; + protected string $pageFields = 'id,name,username,picture{url},access_token'; - protected SocialPlatform $platform = SocialPlatform::Facebook; + protected string $noPagesKey = 'accounts.popup_callback.no_facebook_pages'; - private const PAGE_FIELDS = 'id,name,username,picture{url},access_token'; + protected SocialPlatform $platform = SocialPlatform::Facebook; protected array $scopes = [ 'public_profile', @@ -67,35 +65,20 @@ public function callback(Request $request): InertiaResponse|RedirectResponse try { $socialUser = Socialite::driver($this->driver)->usingGraphVersion($this->graphVersion())->user(); - // Trigger public_profile and pages_show_list API calls - // These calls are needed for Meta app review permission verification - Http::get(config('trypost.platforms.facebook.graph_api').'/me', [ - 'fields' => 'id,name', - 'access_token' => $socialUser->token, - ]); + $this->touchProfile($socialUser->token); - $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); + $granted = $this->grantedScopes($socialUser->token); - if (array_diff($this->platform->requiredPublishScopes(), $granted) !== []) { - return $this->popupCallback(false, __('accounts.popup_callback.pages_missing_permission'), $this->platform->value); + if ($granted instanceof InertiaResponse) { + return $granted; } - $walk = ManagedPages::forUser( - $this->graphApi(), - $socialUser->token, - self::PAGE_FIELDS, - $granted, - ); - + $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted); $listed = $this->toPageCards($walk->pages); $pages = ManagedPages::publishable($listed); if (empty($pages)) { - return $this->popupCallback(false, __(match (true) { - ! $walk->complete => 'accounts.popup_callback.pages_read_incomplete', - empty($listed) => 'accounts.popup_callback.no_facebook_pages', - default => 'accounts.popup_callback.pages_missing_permission', - }), $this->platform->value); + return $this->noPagesOnOffer($walk, $listed); } $pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect); @@ -256,13 +239,8 @@ private function toPageCards(array $pages): array ])->all(); } - private function graphApi(): string - { - return (string) config('trypost.platforms.facebook.graph_api'); - } - private function graphVersion(): string { - return Uri::of(config('trypost.platforms.facebook.graph_api'))->path(); + return Uri::of($this->graphApi())->path(); } } diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 015da32d4..462224035 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -10,7 +10,6 @@ use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; use App\Models\Workspace; -use App\Services\Social\Meta\GrantedPermissions; use App\Services\Social\Meta\ManagedPages; use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\Response as ClientResponse; @@ -26,9 +25,11 @@ use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; -class InstagramFacebookController extends SocialController +class InstagramFacebookController extends MetaController { - protected string $driver = 'facebook'; + protected string $pageFields = 'id,name,username,picture{url},access_token,instagram_business_account'; + + protected string $noPagesKey = 'accounts.popup_callback.no_facebook_instagram_pages'; protected SocialPlatform $platform = SocialPlatform::InstagramFacebook; @@ -40,8 +41,6 @@ class InstagramFacebookController extends SocialController */ private const INSTAGRAM_LOOKUPS_PER_ROUND = 20; - private const PAGE_FIELDS = 'id,name,username,picture{url},access_token,instagram_business_account'; - protected array $scopes = [ 'public_profile', 'pages_show_list', @@ -84,24 +83,15 @@ public function callback(Request $request): InertiaResponse|RedirectResponse ->redirectUrl(route('app.social.instagram-facebook.callback')) ->user(); - // Trigger public_profile API call for Meta app review verification - Http::get(config('trypost.platforms.instagram-facebook.graph_api').'/me', [ - 'fields' => 'id,name', - 'access_token' => $socialUser->token, - ]); + $this->touchProfile($socialUser->token); - $granted = GrantedPermissions::for($this->graphApi(), $socialUser->token, $this->scopes); + $granted = $this->grantedScopes($socialUser->token); - if (array_diff($this->platform->requiredPublishScopes(), $granted) !== []) { - return $this->popupCallback(false, __('accounts.popup_callback.pages_missing_permission'), $this->platform->value); + if ($granted instanceof InertiaResponse) { + return $granted; } - $walk = ManagedPages::forUser( - $this->graphApi(), - $socialUser->token, - self::PAGE_FIELDS, - $granted, - ); + $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted); $listed = collect($walk->pages) ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) @@ -111,11 +101,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse $publishable = ManagedPages::publishable($listed); if (empty($publishable)) { - return $this->popupCallback(false, __(match (true) { - ! $walk->complete => 'accounts.popup_callback.pages_read_incomplete', - empty($listed) => 'accounts.popup_callback.no_facebook_instagram_pages', - default => 'accounts.popup_callback.pages_missing_permission', - }), $this->platform->value); + return $this->noPagesOnOffer($walk, $listed); } $connectable = $this->filterConnectableIdentities( @@ -303,13 +289,8 @@ private function describeRound(Collection $pages): Collection }); } - private function graphApi(): string - { - return (string) config('trypost.platforms.instagram-facebook.graph_api'); - } - private function graphVersion(): string { - return Uri::of(config('trypost.platforms.instagram-facebook.graph_api'))->path(); + return Uri::of($this->graphApi())->path(); } } diff --git a/app/Http/Controllers/Auth/MetaController.php b/app/Http/Controllers/Auth/MetaController.php new file mode 100644 index 000000000..3611e2bbd --- /dev/null +++ b/app/Http/Controllers/Auth/MetaController.php @@ -0,0 +1,61 @@ +graphApi()}/me", ['fields' => 'id,name', 'access_token' => $userToken]); + } + + /** + * The scopes this login did not refuse, or the popup refusing the connect because + * one the platform needs to publish is among them. + * + * @return array|InertiaResponse + */ + protected function grantedScopes(string $userToken): array|InertiaResponse + { + $granted = GrantedPermissions::for($this->graphApi(), $userToken, $this->scopes); + + return array_diff($this->platform->requiredPublishScopes(), $granted) === [] + ? $granted + : $this->popupCallback(false, __('accounts.popup_callback.pages_missing_permission'), $this->platform->value); + } + + /** + * A walk that could not finish outranks the other answers, since neither would be + * true of what it did not read. + * + * @param array> $listed + */ + protected function noPagesOnOffer(ManagedPageList $walk, array $listed): InertiaResponse + { + return $this->popupCallback(false, __(match (true) { + ! $walk->complete => 'accounts.popup_callback.pages_read_incomplete', + empty($listed) => $this->noPagesKey, + default => 'accounts.popup_callback.pages_missing_permission', + }), $this->platform->value); + } +} diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 69fca3fb3..a082b0c9e 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -26,6 +26,12 @@ class SocialController extends Controller { protected SocialPlatform $platform; + /** The platform's API host, keyed in config by the enum value. */ + protected function graphApi(): string + { + return (string) config("trypost.platforms.{$this->platform->value}.graph_api"); + } + protected function ensurePlatformEnabled(): void { if (! $this->platform->isEnabled()) { From 128926e76025a92edf701c289b6dabb4931b1f5b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 00:02:34 -0300 Subject: [PATCH 22/29] fix: default Inertia SSR off, where this project already stands Nothing in the repo starts an SSR process, so the shipped default was describing a setup that does not exist. With it off the test env needs no override of its own, and CLAUDE.md records that turning it on means starting the process, not just flipping the env. --- CLAUDE.md | 6 +++--- config/inertia.php | 2 +- phpunit.xml | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index baa9d28d9..5333ed8d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,9 +210,9 @@ Vue components must have a single root element. ## Inertia SSR -- This project does **not** run Inertia SSR. Nothing in the repo sets `INERTIA_SSR_ENABLED`, and no process starts `inertia:start-ssr`. -- Tests disable it in `phpunit.xml` (`INERTIA_SSR_ENABLED=false`), the same way `PULSE_ENABLED` / `TELESCOPE_ENABLED` / `NIGHTWATCH_ENABLED` are handled. Without it, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering. -- Do **not** fix that by flipping the default in `config/inertia.php` — the SSR build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`), so a changed default would silently turn SSR off wherever it is started for us. +- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false`; nothing in the repo sets `INERTIA_SSR_ENABLED`, and no process starts `inertia:start-ssr`. +- Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs. +- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means starting the SSR process too, not just flipping the env. ## Dialogs diff --git a/config/inertia.php b/config/inertia.php index 4da733d86..002954c1d 100644 --- a/config/inertia.php +++ b/config/inertia.php @@ -23,7 +23,7 @@ 'ssr' => [ - 'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), + 'enabled' => (bool) env('INERTIA_SSR_ENABLED', false), 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), diff --git a/phpunit.xml b/phpunit.xml index 89d137540..97f74bc87 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,7 +33,6 @@ - From 856fd88d330234a5b7364a9d461f4421e891434d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 00:14:55 -0300 Subject: [PATCH 23/29] fix: one rule for a refused portfolio, and a clock on the walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round I made a refused /me/businesses mark the walk incomplete, on the argument that refusing the index means "we could not look". That was wrong in the case that matters most: an app without Advanced Access for business_management gets that refusal on every single connect, so every login on such an install lost auto-connect and every login without Pages was told to retry forever. Self-hosted in Live mode is exactly that. The rule that holds everywhere: a Page this login cannot enumerate is a Page it cannot get a token for, so it was never connectable, and the list of connectable Pages is complete. Only an unknown — a throttle, a hiccup, a budget or a ceiling — leaves the walk unable to vouch for itself. Index and edge now answer the same way, which is also what makes the two readable together. The per-request budgets were each bounded while their sum was not: ten pooled rounds plus twenty-five cursor requests can outlive nginx's fastcgi_read_timeout of 120s. The walk now carries a deadline and returns what it has. touchProfile exists only because Meta's review wants the call. It had no timeout and no guard, so a hung /me could stall the callback to the gateway timeout or fail a connect outright, over a response nobody reads. --- app/Http/Controllers/Auth/MetaController.php | 7 ++- app/Services/Social/Meta/ManagedPages.php | 53 +++++++++++--------- config/trypost.php | 13 +++++ tests/Unit/Social/Meta/ManagedPagesTest.php | 36 ++++++++++++- 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/app/Http/Controllers/Auth/MetaController.php b/app/Http/Controllers/Auth/MetaController.php index 3611e2bbd..0580b578f 100644 --- a/app/Http/Controllers/Auth/MetaController.php +++ b/app/Http/Controllers/Auth/MetaController.php @@ -23,10 +23,13 @@ abstract class MetaController extends SocialController /** Popup key for "this login has no pages of the kind we want". */ protected string $noPagesKey; - /** Meta's app review wants to see this called; the answer is unused. */ + /** Meta's app review wants to see this called; the answer is unused, so nothing it does can fail the connect. */ protected function touchProfile(string $userToken): void { - Http::get("{$this->graphApi()}/me", ['fields' => 'id,name', 'access_token' => $userToken]); + rescue(fn () => Http::timeout(5)->connectTimeout(5)->get("{$this->graphApi()}/me", [ + 'fields' => 'id,name', + 'access_token' => $userToken, + ]), report: false); } /** diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 261d11c95..fa78c868e 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -40,11 +40,15 @@ class ManagedPages private int $continuations = 0; + private readonly float $deadline; + private function __construct( private readonly string $graphApi, private readonly string $userToken, private readonly string $fields, - ) {} + ) { + $this->deadline = microtime(true) + (int) config('trypost.meta_page_walk_seconds'); + } /** * @param array $grantedScopes @@ -101,18 +105,23 @@ private function walk(array $grantedScopes): ManagedPageList */ private function portfolioPages(): Collection { - $rounds = collect($this->businessIds()) + return collect($this->businessIds()) ->crossJoin(['owned_pages', 'client_pages']) ->map(fn (array $edge) => Uri::of("{$this->graphApi}/{$edge[0]}/{$edge[1]}")->withQuery($this->query())->value()) - ->chunk(self::EDGES_PER_ROUND); - - $pages = collect(); + ->chunk(self::EDGES_PER_ROUND) + ->flatMap($this->readRound(...)); + } - foreach ($rounds as $round) { - $pages = $pages->concat($this->readRound($round)); + /** Every per-request budget is bounded, but the walk sits in an OAuth callback. */ + private function outOfTime(): bool + { + if (microtime(true) < $this->deadline) { + return false; } - return $pages; + $this->complete = false; + + return true; } /** @@ -121,35 +130,33 @@ private function portfolioPages(): Collection */ private function readRound(Collection $urls): Collection { + if ($this->outOfTime()) { + return collect(); + } + $urls = $urls->values(); $responses = Http::pool(fn (Pool $pool) => $urls ->map(fn (string $url) => $pool->timeout(15)->connectTimeout(5)->get($url)) ->all()); - $pages = collect(); - - foreach ($urls as $index => $url) { + return $urls->flatMap(function (string $url, int $index) use ($responses) { $response = data_get($responses, $index); if (! $response instanceof Response) { $this->complete = false; - continue; + return []; } if ($response->failed()) { $this->note($url, $response); - continue; + return []; } - $pages = $pages->concat($response->collect('data'))->concat( - $this->rest($url, $response->json('paging.next')), - ); - } - - return $pages; + return $response->collect('data')->concat($this->rest($url, $response->json('paging.next'))); + }); } /** @@ -164,7 +171,7 @@ private function rest(string $url, mixed $next): array $pages = []; while (is_string($next) && filled($next)) { - if ($this->continuations >= self::MAX_CONTINUATIONS || Uri::of($next)->host() !== Uri::of($url)->host()) { + if ($this->continuations >= self::MAX_CONTINUATIONS || $this->outOfTime() || Uri::of($next)->host() !== Uri::of($url)->host()) { $this->complete = false; break; @@ -198,9 +205,6 @@ private function rest(string $url, mixed $next): array * Reading one page is what bounds the walk: paginating here would let one login * spawn thousands of edge reads. More portfolios than fit is incomplete, not failed. * - * A refusal here is not "this login has no portfolios" — it is "we could not look", - * which is the difference between an edge and the index of edges. - * * @return list */ private function businessIds(): array @@ -219,8 +223,7 @@ private function businessIds(): array } if ($response->failed()) { - GraphPaginator::failure($url, $response); - $this->complete = false; + $this->note($url, $response); return []; } diff --git a/config/trypost.php b/config/trypost.php index a2e7cbd72..8b6a1dc7d 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -16,6 +16,19 @@ 'self_hosted' => env('SELF_HOSTED', true), + /* + |-------------------------------------------------------------------------- + | Meta page walk budget + |-------------------------------------------------------------------------- + | + | Seconds the Facebook/Instagram page walk may spend before it returns what + | it has and reports itself incomplete. It runs inside the OAuth callback, + | so this must stay well under the web server's request timeout. + | + */ + + 'meta_page_walk_seconds' => (int) env('META_PAGE_WALK_SECONDS', 20), + /* |-------------------------------------------------------------------------- | Multiple social accounts per network diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index cdd407b36..86d8ebfdf 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -131,7 +131,7 @@ function managedPagesIds(ManagedPageList $walk): array Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); }); -test('a refused portfolio index means we could not look, not that there is nothing', function () { +test('an app without business_management reaches no portfolio pages, and that is an answer', function () { $graphApi = managedPagesGraphApi(); $walk = managedPagesWalk([ @@ -143,10 +143,44 @@ function managedPagesIds(ManagedPageList $walk): array ], 403), ]); + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeTrue(); +}); + +test('a throttled portfolio index leaves the walk unable to vouch for itself', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'code' => 4], + ], 400), + ]); + expect(managedPagesIds($walk))->toBe(['page_1']) ->and($walk->complete)->toBeFalse(); }); +test('the walk gives up on time rather than outliving the request', function () { + config()->set('trypost.meta_page_walk_seconds', 0); + + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '_pages')); +}); + test('a refused single edge is an answer about that edge, and leaves the walk complete', function () { $graphApi = managedPagesGraphApi(); From 315e4bc545fc45263b5033110644051a3ac55371 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 00:20:00 -0300 Subject: [PATCH 24/29] docs: the walk's contract changed under its own docblock It still said any failure marks the walk incomplete, which stopped being true when a refusal became an answer. A docblock describing an invariant the code no longer holds is how this branch got two of its bugs. --- app/Services/Social/Meta/ManagedPages.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index fa78c868e..3e7a0df30 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -19,8 +19,11 @@ * Portfolio admin — the New Pages Experience norm — gets nothing there. The * portfolio's `owned_pages` and `client_pages` edges are read too and merged by id. * - * Only `/me/accounts` failing is fatal. Anything else keeps what arrived and reports - * the walk incomplete, so no caller auto-connects off a list it cannot vouch for. + * Only `/me/accounts` failing is fatal; everything else keeps what arrived. A refusal + * is an answer — a Page this login cannot enumerate is one it cannot get a token for, + * so it was never connectable. An unknown is not: a throttle, a hiccup, a budget or a + * ceiling leaves the walk unable to vouch for itself, and it says so, so no caller + * auto-connects off a list that may be missing something. */ class ManagedPages { From 9f5f6c4e9b70ada5ad1ee8c88c011982f9dfd838 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 00:31:32 -0300 Subject: [PATCH 25/29] fix: put the whole callback inside the budget it advertises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit meta_page_walk_seconds bounded the portfolio half of the walk and nothing else. /me/accounts could paginate a hundred pages at fifteen seconds each, and the Instagram lookups pooled in rounds that were themselves serial — a portfolio with three hundred linked Pages is fifteen rounds, after the walk had already spent its own budget. Both honour the deadline now. The lookups skip rather than drop: the Page still connects, only its handle and avatar arrive empty. The first request is always made; the budget bounds what comes after it. A Graph body that is valid JSON but not an object — a proxy answering "throttled" — reached GraphError::isTransient, whose parameter is ?array, and under strict_types raised a TypeError. That is an Error, so it walked past both callbacks' catch(\Exception) and 500'd the popup instead of showing a message. Refusing a login before any listing has happened no longer borrows the wording for "we found your Pages but not the permission to post to them". composer run dev no longer starts an SSR process for SSR that is off, and CLAUDE.md no longer claims nothing in the repo starts one, which composer.json contradicted. --- CLAUDE.md | 4 ++-- .../Auth/InstagramFacebookController.php | 11 ++++++++--- app/Http/Controllers/Auth/MetaController.php | 2 +- app/Services/Social/Meta/GraphError.php | 2 +- app/Services/Social/Meta/GraphPaginator.php | 7 ++++++- app/Services/Social/Meta/ManagedPages.php | 2 +- composer.json | 2 +- lang/ar/accounts.php | 1 + lang/de/accounts.php | 1 + lang/el/accounts.php | 1 + lang/en/accounts.php | 1 + lang/es/accounts.php | 1 + lang/fr/accounts.php | 1 + lang/it/accounts.php | 1 + lang/ja/accounts.php | 1 + lang/ko/accounts.php | 1 + lang/nl/accounts.php | 1 + lang/pl/accounts.php | 1 + lang/pt-BR/accounts.php | 1 + lang/ru/accounts.php | 1 + lang/tr/accounts.php | 1 + lang/uk/accounts.php | 1 + lang/zh/accounts.php | 1 + tests/Feature/Social/FacebookControllerTest.php | 2 +- tests/Unit/Social/Meta/ManagedPagesTest.php | 13 +++++++++++++ 25 files changed, 50 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5333ed8d4..3ec2deb86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,9 +210,9 @@ Vue components must have a single root element. ## Inertia SSR -- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false`; nothing in the repo sets `INERTIA_SSR_ENABLED`, and no process starts `inertia:start-ssr`. +- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false` and nothing in the repo sets `INERTIA_SSR_ENABLED`. - Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs. -- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means starting the SSR process too, not just flipping the env. +- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env. ## Dialogs diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index 462224035..c3f96af54 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -246,23 +246,28 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, */ private function describeInstagramAccounts(array $pages): array { + $deadline = microtime(true) + (int) config('trypost.meta_page_walk_seconds'); + return collect($pages) ->chunk(self::INSTAGRAM_LOOKUPS_PER_ROUND) - ->flatMap($this->describeRound(...)) + ->flatMap(fn (Collection $round) => $this->describeRound($round, $deadline)) ->values() ->all(); } /** + * Past the deadline the lookups are skipped rather than dropped: the Page still + * connects, only its Instagram handle and avatar arrive empty. + * * @param Collection> $pages * @return Collection> */ - private function describeRound(Collection $pages): Collection + private function describeRound(Collection $pages, float $deadline): Collection { $pages = $pages->values(); $graphApi = $this->graphApi(); - $responses = Http::pool(fn (Pool $pool) => $pages + $responses = microtime(true) >= $deadline ? [] : Http::pool(fn (Pool $pool) => $pages ->map(fn (array $page) => $pool ->timeout(15) ->connectTimeout(5) diff --git a/app/Http/Controllers/Auth/MetaController.php b/app/Http/Controllers/Auth/MetaController.php index 0580b578f..9185c4add 100644 --- a/app/Http/Controllers/Auth/MetaController.php +++ b/app/Http/Controllers/Auth/MetaController.php @@ -44,7 +44,7 @@ protected function grantedScopes(string $userToken): array|InertiaResponse return array_diff($this->platform->requiredPublishScopes(), $granted) === [] ? $granted - : $this->popupCallback(false, __('accounts.popup_callback.pages_missing_permission'), $this->platform->value); + : $this->popupCallback(false, __('accounts.popup_callback.publish_permission_refused'), $this->platform->value); } /** diff --git a/app/Services/Social/Meta/GraphError.php b/app/Services/Social/Meta/GraphError.php index f81084dd4..2c23570ab 100644 --- a/app/Services/Social/Meta/GraphError.php +++ b/app/Services/Social/Meta/GraphError.php @@ -73,7 +73,7 @@ public static function isTransientFailure(Response $response): bool { return $response->serverError() || $response->status() === 429 - || self::isTransient($response->json()); + || self::isTransient(is_array($body = $response->json()) ? $body : null); } /** diff --git a/app/Services/Social/Meta/GraphPaginator.php b/app/Services/Social/Meta/GraphPaginator.php index 8328617e2..b19a5d879 100644 --- a/app/Services/Social/Meta/GraphPaginator.php +++ b/app/Services/Social/Meta/GraphPaginator.php @@ -29,11 +29,12 @@ class GraphPaginator /** * @param array $query + * @param float|null $deadline microtime after which no *further* page is fetched; the first always is * @return list> * * @throws IncompleteMetaGraphPaginationException */ - public static function all(string $url, array $query = []): array + public static function all(string $url, array $query = [], ?float $deadline = null): array { $items = collect(); $fetched = 0; @@ -50,6 +51,10 @@ public static function all(string $url, array $query = []): array self::abort($next, $fetched, reason: 'Meta Graph pagination stopped: repeated paging URL'); } + if ($fetched > 0 && $deadline !== null && microtime(true) >= $deadline) { + self::abort($next, $fetched, reason: 'Meta Graph pagination stopped: out of time'); + } + $seen[$next] = true; try { diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 3e7a0df30..7c7ef4f90 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -87,7 +87,7 @@ public static function publishable(array $pages): array */ private function walk(array $grantedScopes): ManagedPageList { - $pages = collect(GraphPaginator::all("{$this->graphApi}/me/accounts", $this->query())); + $pages = collect(GraphPaginator::all("{$this->graphApi}/me/accounts", $this->query(), $this->deadline)); if (in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true)) { $pages = $pages->concat($this->portfolioPages()); diff --git a/composer.json b/composer.json index f330b8154..2e8b6aa21 100644 --- a/composer.json +++ b/composer.json @@ -108,7 +108,7 @@ "dev:ssr": [ "npm run build:ssr", "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" --names=server,queue,logs --kill-others" ], "lint": [ "pint --parallel" diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index c6a7101f9..762509b3a 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'لم يتم العثور على الصفحة.', 'channel_not_found' => 'لم يتم العثور على القناة.', 'pages_read_incomplete' => 'لم نتمكن من إكمال قراءة صفحاتك. حاول مرة أخرى بعد قليل.', + 'publish_permission_refused' => 'رفض هذا الحساب إذنًا نحتاجه للنشر. أعد الاتصال واقبل جميع الأذونات.', 'pages_missing_permission' => 'وجدنا صفحاتك ولكن ليس إذن النشر فيها. أعد الاتصال واقبل جميع الأذونات.', 'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.', 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index ccc0ce02a..4865af9f9 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -158,6 +158,7 @@ 'page_not_found' => 'Seite nicht gefunden.', 'channel_not_found' => 'Kanal nicht gefunden.', 'pages_read_incomplete' => 'Wir konnten deine Seiten nicht vollständig lesen. Bitte versuche es gleich noch einmal.', + 'publish_permission_refused' => 'Diese Anmeldung hat eine zum Posten nötige Berechtigung abgelehnt. Verbinde erneut und akzeptiere alle.', 'pages_missing_permission' => 'Wir haben deine Seiten gefunden, aber nicht die Berechtigung, dort zu posten. Verbinde erneut und akzeptiere alle Berechtigungen.', 'no_facebook_pages' => 'Keine Facebook-Seiten gefunden. Du musst Administrator mindestens einer Seite sein.', 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 4ac117733..b49b69479 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Η σελίδα δεν βρέθηκε.', 'channel_not_found' => 'Το κανάλι δεν βρέθηκε.', 'pages_read_incomplete' => 'Δεν μπορέσαμε να διαβάσουμε όλες τις Σελίδες σας. Δοκιμάστε ξανά σε λίγο.', + 'publish_permission_refused' => 'Αυτή η σύνδεση αρνήθηκε μια άδεια που χρειαζόμαστε για δημοσίευση. Συνδεθείτε ξανά και αποδεχτείτε όλες.', 'pages_missing_permission' => 'Βρήκαμε τις Σελίδες σας, αλλά όχι την άδεια δημοσίευσης σε αυτές. Συνδεθείτε ξανά και αποδεχτείτε όλες τις άδειες.', 'no_facebook_pages' => 'Δεν βρέθηκαν σελίδες Facebook. Πρέπει να είστε διαχειριστής τουλάχιστον μίας σελίδας.', 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index b722d284d..5b0e45d75 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Page not found.', 'channel_not_found' => 'Channel not found.', 'pages_read_incomplete' => 'We could not finish reading your Pages. Please try again in a moment.', + 'publish_permission_refused' => 'This login refused a permission we need to post. Reconnect and accept all of them.', 'pages_missing_permission' => 'We found your Pages but not the permission to post to them. Reconnect and accept every permission.', 'no_facebook_pages' => 'No Facebook Pages found. You need to be an admin of at least one page.', 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index f6462ca50..aeb3612fe 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Página no encontrada.', 'channel_not_found' => 'Canal no encontrado.', 'pages_read_incomplete' => 'No pudimos terminar de leer tus páginas. Inténtalo de nuevo en un momento.', + 'publish_permission_refused' => 'Este inicio de sesión rechazó un permiso necesario para publicar. Vuelve a conectar y acéptalos todos.', 'pages_missing_permission' => 'Encontramos tus páginas, pero no el permiso para publicar en ellas. Vuelve a conectar y acepta todos los permisos.', 'no_facebook_pages' => 'No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.', 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index a58d4edb5..a1893a87f 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Page introuvable.', 'channel_not_found' => 'Chaîne introuvable.', 'pages_read_incomplete' => 'Nous n’avons pas pu finir de lire vos Pages. Réessayez dans un instant.', + 'publish_permission_refused' => 'Cette connexion a refusé une autorisation nécessaire pour publier. Reconnectez-vous en les acceptant toutes.', 'pages_missing_permission' => 'Nous avons trouvé vos Pages, mais pas l’autorisation d’y publier. Reconnectez-vous en acceptant toutes les autorisations.', 'no_facebook_pages' => 'Aucune page Facebook trouvée. Vous devez être administrateur d\'au moins une page.', 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index d56a22876..da01a3dfa 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Pagina non trovata.', 'channel_not_found' => 'Canale non trovato.', 'pages_read_incomplete' => 'Non siamo riusciti a leggere tutte le tue Pagine. Riprova tra poco.', + 'publish_permission_refused' => 'Questo accesso ha rifiutato una autorizzazione necessaria per pubblicare. Riconnetti accettandole tutte.', 'pages_missing_permission' => 'Abbiamo trovato le tue Pagine, ma non l’autorizzazione a pubblicarci. Riconnetti accettando tutte le autorizzazioni.', 'no_facebook_pages' => 'Nessuna pagina Facebook trovata. Devi essere amministratore di almeno una pagina.', 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 3c1b72c69..c31b0dac2 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'ページが見つかりません。', 'channel_not_found' => 'チャンネルが見つかりません。', 'pages_read_incomplete' => 'ページをすべて読み取れませんでした。少し時間をおいて再度お試しください。', + 'publish_permission_refused' => '投稿に必要な権限が許可されませんでした。再接続してすべて許可してください。', 'pages_missing_permission' => 'ページは見つかりましたが、投稿する権限がありません。再接続してすべての権限を許可してください。', 'no_facebook_pages' => 'Facebook ページが見つかりません。少なくとも 1 つのページの管理者である必要があります。', 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 87a692e44..ddf7517a0 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => '페이지를 찾을 수 없습니다.', 'channel_not_found' => '채널을 찾을 수 없습니다.', 'pages_read_incomplete' => '페이지를 모두 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.', + 'publish_permission_refused' => '게시에 필요한 권한이 거부되었습니다. 다시 연결하고 모두 허용해 주세요.', 'pages_missing_permission' => '페이지는 찾았지만 게시 권한이 없습니다. 다시 연결하고 모든 권한을 허용해 주세요.', 'no_facebook_pages' => 'Facebook 페이지를 찾을 수 없습니다. 최소 한 개 페이지의 관리자여야 합니다.', 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 71cbb00a0..26be87f42 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Pagina niet gevonden.', 'channel_not_found' => 'Kanaal niet gevonden.', 'pages_read_incomplete' => 'We konden je pagina’s niet volledig uitlezen. Probeer het zo meteen opnieuw.', + 'publish_permission_refused' => 'Deze login heeft een recht geweigerd dat we nodig hebben om te posten. Maak opnieuw verbinding en accepteer alles.', 'pages_missing_permission' => 'We hebben je pagina’s gevonden, maar niet de rechten om erop te posten. Maak opnieuw verbinding en accepteer alle rechten.', 'no_facebook_pages' => 'Geen Facebook-pagina\'s gevonden. Je moet beheerder zijn van ten minste één pagina.', 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 024554ed5..734b59340 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Nie znaleziono strony.', 'channel_not_found' => 'Nie znaleziono kanału.', 'pages_read_incomplete' => 'Nie udało się odczytać wszystkich Twoich stron. Spróbuj ponownie za chwilę.', + 'publish_permission_refused' => 'To logowanie odrzuciło uprawnienie potrzebne do publikowania. Połącz ponownie i zaakceptuj wszystkie.', 'pages_missing_permission' => 'Znaleźliśmy Twoje strony, ale nie uprawnienia do publikowania na nich. Połącz ponownie i zaakceptuj wszystkie uprawnienia.', 'no_facebook_pages' => 'Nie znaleziono stron na Facebooku. Musisz być administratorem co najmniej jednej strony.', 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index a85026697..3cb6fc09f 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Página não encontrada.', 'channel_not_found' => 'Canal não encontrado.', 'pages_read_incomplete' => 'Não conseguimos terminar de ler suas páginas. Tente novamente em instantes.', + 'publish_permission_refused' => 'Este login recusou uma permissão necessária para publicar. Reconecte aceitando todas.', 'pages_missing_permission' => 'Encontramos suas páginas, mas não a permissão para publicar nelas. Reconecte aceitando todas as permissões.', 'no_facebook_pages' => 'Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.', 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index c925777d1..391e99c2b 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Страница не найдена.', 'channel_not_found' => 'Канал не найден.', 'pages_read_incomplete' => 'Не удалось прочитать все ваши страницы. Попробуйте ещё раз через минуту.', + 'publish_permission_refused' => 'При входе отклонено разрешение, нужное для публикации. Подключитесь заново и примите все.', 'pages_missing_permission' => 'Мы нашли ваши страницы, но не разрешение публиковать на них. Подключитесь заново и примите все разрешения.', 'no_facebook_pages' => 'Страницы Facebook не найдены. Вы должны быть администратором хотя бы одной страницы.', 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 833af6847..5154e9661 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -158,6 +158,7 @@ 'page_not_found' => 'Sayfa bulunamadı.', 'channel_not_found' => 'Kanal bulunamadı.', 'pages_read_incomplete' => 'Sayfalarınızın tamamını okuyamadık. Birazdan tekrar deneyin.', + 'publish_permission_refused' => 'Bu girişte paylaşım için gereken bir izin reddedildi. Yeniden bağlanıp hepsini kabul edin.', 'pages_missing_permission' => 'Sayfalarınızı bulduk ama orada paylaşım izni bulamadık. Yeniden bağlanıp tüm izinleri kabul edin.', 'no_facebook_pages' => 'Facebook Sayfası bulunamadı. En az bir sayfanın yöneticisi olmanız gerekir.', 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index af1a824ba..4c1cfbea3 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => 'Сторінку не знайдено.', 'channel_not_found' => 'Канал не знайдено.', 'pages_read_incomplete' => 'Не вдалося прочитати всі ваші сторінки. Спробуйте ще раз за хвилину.', + 'publish_permission_refused' => 'Під час входу відхилено дозвіл, потрібний для публікації. Підключіться знову та надайте всі.', 'pages_missing_permission' => 'Ми знайшли ваші сторінки, але не дозвіл публікувати на них. Підключіться знову та надайте всі дозволи.', 'no_facebook_pages' => 'Сторінок Facebook не знайдено. Ви маєте бути адміністратором хоча б однієї сторінки.', 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index edd675d5f..1b9740c47 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -156,6 +156,7 @@ 'page_not_found' => '未找到页面。', 'channel_not_found' => '未找到频道。', 'pages_read_incomplete' => '我们没能读取你的全部主页。请稍后再试。', + 'publish_permission_refused' => '本次登录拒绝了发布所需的权限。请重新连接并接受全部权限。', 'pages_missing_permission' => '我们找到了你的主页,但没有发布权限。请重新连接并接受所有权限。', 'no_facebook_pages' => '未找到 Facebook 主页。你至少需要是一个主页的管理员。', 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index e24becc80..8b3e627db 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -1362,7 +1362,7 @@ ->get(route('app.social.facebook.callback')) ->assertInertia(fn (AssertableInertia $page) => $page ->where('success', false) - ->where('message', __('accounts.popup_callback.pages_missing_permission'))); + ->where('message', __('accounts.popup_callback.publish_permission_refused'))); $this->assertDatabaseCount('social_accounts', 0); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/accounts')); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 86d8ebfdf..20aa2da6b 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -488,3 +488,16 @@ function managedPagesIds(ManagedPageList $walk): array expect($walk->complete)->toBeFalse(); }); + +test('the deadline stops me/accounts from paginating forever', function () { + config()->set('trypost.meta_page_walk_seconds', 0); + + $graphApi = managedPagesGraphApi(); + + managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']], + 'paging' => ['next' => "{$graphApi}/me/accounts?access_token=user-token&after=c1"], + ], 200), + ]); +})->throws(IncompleteMetaGraphPaginationException::class); From 117dbbb8055bbf93259a98f17207fc8d8cfeafd7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 00:35:33 -0300 Subject: [PATCH 26/29] fix: say what actually gets a Page token, per Meta's own reference The Page node reference is explicit: access_token is "only returned if the User making the request has a role (other than Live Contributor) on the Page". Being an admin of the portfolio that owns a Page lists it but does not grant that role, so the walk can surface Pages this login will never get a token for. The popup told those users to reconnect and accept every permission, which cannot produce a Page role and so could never work. It now names the role as well. I rejected this in review on the grounds that a portfolio Page had been published to successfully in the wild. That proved a token comes back when the login holds a role, not that one always does. --- app/Services/Social/Meta/ManagedPages.php | 4 +++- lang/ar/accounts.php | 2 +- lang/de/accounts.php | 2 +- lang/el/accounts.php | 2 +- lang/en/accounts.php | 2 +- lang/es/accounts.php | 2 +- lang/fr/accounts.php | 2 +- lang/it/accounts.php | 2 +- lang/ja/accounts.php | 2 +- lang/ko/accounts.php | 2 +- lang/nl/accounts.php | 2 +- lang/pl/accounts.php | 2 +- lang/pt-BR/accounts.php | 2 +- lang/ru/accounts.php | 2 +- lang/tr/accounts.php | 2 +- lang/uk/accounts.php | 2 +- lang/zh/accounts.php | 2 +- 17 files changed, 19 insertions(+), 17 deletions(-) diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 7c7ef4f90..851752d8a 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -68,7 +68,9 @@ public static function forUser( } /** - * A Page Meta lists without an `access_token` would connect into an account that + * Meta returns `access_token` on a Page only when the login holds a role on that + * Page — being in the portfolio that owns it is not enough — so a portfolio can + * list Pages this login cannot post to. Connecting one produces an account that * cannot publish, so callers separate it from a Page they never had. * * @param array> $pages diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 762509b3a..500991a2d 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'لم يتم العثور على القناة.', 'pages_read_incomplete' => 'لم نتمكن من إكمال قراءة صفحاتك. حاول مرة أخرى بعد قليل.', 'publish_permission_refused' => 'رفض هذا الحساب إذنًا نحتاجه للنشر. أعد الاتصال واقبل جميع الأذونات.', - 'pages_missing_permission' => 'وجدنا صفحاتك ولكن ليس إذن النشر فيها. أعد الاتصال واقبل جميع الأذونات.', + 'pages_missing_permission' => 'وجدنا صفحات، لكن لا يمكنك النشر في أي منها. تحتاج إلى دور على الصفحة نفسها وقبول جميع الأذونات.', 'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.', 'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 4865af9f9..f9574718d 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -159,7 +159,7 @@ 'channel_not_found' => 'Kanal nicht gefunden.', 'pages_read_incomplete' => 'Wir konnten deine Seiten nicht vollständig lesen. Bitte versuche es gleich noch einmal.', 'publish_permission_refused' => 'Diese Anmeldung hat eine zum Posten nötige Berechtigung abgelehnt. Verbinde erneut und akzeptiere alle.', - 'pages_missing_permission' => 'Wir haben deine Seiten gefunden, aber nicht die Berechtigung, dort zu posten. Verbinde erneut und akzeptiere alle Berechtigungen.', + 'pages_missing_permission' => 'Wir haben Seiten gefunden, aber keine zum Posten. Du brauchst eine Rolle auf der Seite selbst und alle Berechtigungen.', 'no_facebook_pages' => 'Keine Facebook-Seiten gefunden. Du musst Administrator mindestens einer Seite sein.', 'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.', 'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index b49b69479..2b2fffaf7 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Το κανάλι δεν βρέθηκε.', 'pages_read_incomplete' => 'Δεν μπορέσαμε να διαβάσουμε όλες τις Σελίδες σας. Δοκιμάστε ξανά σε λίγο.', 'publish_permission_refused' => 'Αυτή η σύνδεση αρνήθηκε μια άδεια που χρειαζόμαστε για δημοσίευση. Συνδεθείτε ξανά και αποδεχτείτε όλες.', - 'pages_missing_permission' => 'Βρήκαμε τις Σελίδες σας, αλλά όχι την άδεια δημοσίευσης σε αυτές. Συνδεθείτε ξανά και αποδεχτείτε όλες τις άδειες.', + 'pages_missing_permission' => 'Βρήκαμε Σελίδες, αλλά σε καμία δεν μπορείτε να δημοσιεύσετε. Χρειάζεστε ρόλο στην ίδια τη Σελίδα και όλες τις άδειες.', 'no_facebook_pages' => 'Δεν βρέθηκαν σελίδες Facebook. Πρέπει να είστε διαχειριστής τουλάχιστον μίας σελίδας.', 'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 5b0e45d75..ce62dbfdb 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Channel not found.', 'pages_read_incomplete' => 'We could not finish reading your Pages. Please try again in a moment.', 'publish_permission_refused' => 'This login refused a permission we need to post. Reconnect and accept all of them.', - 'pages_missing_permission' => 'We found your Pages but not the permission to post to them. Reconnect and accept every permission.', + 'pages_missing_permission' => 'We found Pages, but none you can post to. You need a role on the Page itself, and every permission accepted.', 'no_facebook_pages' => 'No Facebook Pages found. You need to be an admin of at least one page.', 'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.', 'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index aeb3612fe..f1f273393 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Canal no encontrado.', 'pages_read_incomplete' => 'No pudimos terminar de leer tus páginas. Inténtalo de nuevo en un momento.', 'publish_permission_refused' => 'Este inicio de sesión rechazó un permiso necesario para publicar. Vuelve a conectar y acéptalos todos.', - 'pages_missing_permission' => 'Encontramos tus páginas, pero no el permiso para publicar en ellas. Vuelve a conectar y acepta todos los permisos.', + 'pages_missing_permission' => 'Encontramos páginas, pero ninguna en la que puedas publicar. Necesitas un rol en la página y aceptar todos los permisos.', 'no_facebook_pages' => 'No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.', 'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.', 'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index a1893a87f..7ba02b5fe 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Chaîne introuvable.', 'pages_read_incomplete' => 'Nous n’avons pas pu finir de lire vos Pages. Réessayez dans un instant.', 'publish_permission_refused' => 'Cette connexion a refusé une autorisation nécessaire pour publier. Reconnectez-vous en les acceptant toutes.', - 'pages_missing_permission' => 'Nous avons trouvé vos Pages, mais pas l’autorisation d’y publier. Reconnectez-vous en acceptant toutes les autorisations.', + 'pages_missing_permission' => 'Nous avons trouvé des Pages, mais aucune où publier. Il vous faut un rôle sur la Page elle-même et toutes les autorisations acceptées.', 'no_facebook_pages' => 'Aucune page Facebook trouvée. Vous devez être administrateur d\'au moins une page.', 'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.', 'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index da01a3dfa..20d2deb5f 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Canale non trovato.', 'pages_read_incomplete' => 'Non siamo riusciti a leggere tutte le tue Pagine. Riprova tra poco.', 'publish_permission_refused' => 'Questo accesso ha rifiutato una autorizzazione necessaria per pubblicare. Riconnetti accettandole tutte.', - 'pages_missing_permission' => 'Abbiamo trovato le tue Pagine, ma non l’autorizzazione a pubblicarci. Riconnetti accettando tutte le autorizzazioni.', + 'pages_missing_permission' => 'Abbiamo trovato Pagine, ma nessuna su cui pubblicare. Serve un ruolo sulla Pagina stessa e tutte le autorizzazioni accettate.', 'no_facebook_pages' => 'Nessuna pagina Facebook trovata. Devi essere amministratore di almeno una pagina.', 'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.', 'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index c31b0dac2..23f151420 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'チャンネルが見つかりません。', 'pages_read_incomplete' => 'ページをすべて読み取れませんでした。少し時間をおいて再度お試しください。', 'publish_permission_refused' => '投稿に必要な権限が許可されませんでした。再接続してすべて許可してください。', - 'pages_missing_permission' => 'ページは見つかりましたが、投稿する権限がありません。再接続してすべての権限を許可してください。', + 'pages_missing_permission' => 'ページは見つかりましたが、投稿できるものがありません。ページ自体での役割と、すべての権限が必要です。', 'no_facebook_pages' => 'Facebook ページが見つかりません。少なくとも 1 つのページの管理者である必要があります。', 'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index ddf7517a0..48c2c06cc 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => '채널을 찾을 수 없습니다.', 'pages_read_incomplete' => '페이지를 모두 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.', 'publish_permission_refused' => '게시에 필요한 권한이 거부되었습니다. 다시 연결하고 모두 허용해 주세요.', - 'pages_missing_permission' => '페이지는 찾았지만 게시 권한이 없습니다. 다시 연결하고 모든 권한을 허용해 주세요.', + 'pages_missing_permission' => '페이지는 찾았지만 게시할 수 있는 곳이 없습니다. 페이지 자체의 역할과 모든 권한이 필요합니다.', 'no_facebook_pages' => 'Facebook 페이지를 찾을 수 없습니다. 최소 한 개 페이지의 관리자여야 합니다.', 'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 26be87f42..835ec17d1 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Kanaal niet gevonden.', 'pages_read_incomplete' => 'We konden je pagina’s niet volledig uitlezen. Probeer het zo meteen opnieuw.', 'publish_permission_refused' => 'Deze login heeft een recht geweigerd dat we nodig hebben om te posten. Maak opnieuw verbinding en accepteer alles.', - 'pages_missing_permission' => 'We hebben je pagina’s gevonden, maar niet de rechten om erop te posten. Maak opnieuw verbinding en accepteer alle rechten.', + 'pages_missing_permission' => 'We vonden pagina\'s, maar geen waar je op kunt posten. Je hebt een rol op de pagina zelf nodig en alle rechten.', 'no_facebook_pages' => 'Geen Facebook-pagina\'s gevonden. Je moet beheerder zijn van ten minste één pagina.', 'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.', 'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 734b59340..0d5b98a82 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Nie znaleziono kanału.', 'pages_read_incomplete' => 'Nie udało się odczytać wszystkich Twoich stron. Spróbuj ponownie za chwilę.', 'publish_permission_refused' => 'To logowanie odrzuciło uprawnienie potrzebne do publikowania. Połącz ponownie i zaakceptuj wszystkie.', - 'pages_missing_permission' => 'Znaleźliśmy Twoje strony, ale nie uprawnienia do publikowania na nich. Połącz ponownie i zaakceptuj wszystkie uprawnienia.', + 'pages_missing_permission' => 'Znaleźliśmy strony, ale na żadnej nie możesz publikować. Potrzebujesz roli na samej stronie i wszystkich uprawnień.', 'no_facebook_pages' => 'Nie znaleziono stron na Facebooku. Musisz być administratorem co najmniej jednej strony.', 'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.', 'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 3cb6fc09f..cb95d308d 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Canal não encontrado.', 'pages_read_incomplete' => 'Não conseguimos terminar de ler suas páginas. Tente novamente em instantes.', 'publish_permission_refused' => 'Este login recusou uma permissão necessária para publicar. Reconecte aceitando todas.', - 'pages_missing_permission' => 'Encontramos suas páginas, mas não a permissão para publicar nelas. Reconecte aceitando todas as permissões.', + 'pages_missing_permission' => 'Encontramos páginas, mas nenhuma em que você possa publicar. É preciso ter um cargo na própria página e aceitar todas as permissões.', 'no_facebook_pages' => 'Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.', 'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.', 'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 391e99c2b..32023ef44 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Канал не найден.', 'pages_read_incomplete' => 'Не удалось прочитать все ваши страницы. Попробуйте ещё раз через минуту.', 'publish_permission_refused' => 'При входе отклонено разрешение, нужное для публикации. Подключитесь заново и примите все.', - 'pages_missing_permission' => 'Мы нашли ваши страницы, но не разрешение публиковать на них. Подключитесь заново и примите все разрешения.', + 'pages_missing_permission' => 'Мы нашли страницы, но публиковать не на чем. Нужна роль на самой странице и все разрешения.', 'no_facebook_pages' => 'Страницы Facebook не найдены. Вы должны быть администратором хотя бы одной страницы.', 'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 5154e9661..dafacd2ff 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -159,7 +159,7 @@ 'channel_not_found' => 'Kanal bulunamadı.', 'pages_read_incomplete' => 'Sayfalarınızın tamamını okuyamadık. Birazdan tekrar deneyin.', 'publish_permission_refused' => 'Bu girişte paylaşım için gereken bir izin reddedildi. Yeniden bağlanıp hepsini kabul edin.', - 'pages_missing_permission' => 'Sayfalarınızı bulduk ama orada paylaşım izni bulamadık. Yeniden bağlanıp tüm izinleri kabul edin.', + 'pages_missing_permission' => 'Sayfalar bulduk ama paylaşım yapabileceğiniz yok. Sayfanın kendisinde bir rolünüz ve tüm izinler gerekli.', 'no_facebook_pages' => 'Facebook Sayfası bulunamadı. En az bir sayfanın yöneticisi olmanız gerekir.', 'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.', 'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index 4c1cfbea3..20b510076 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => 'Канал не знайдено.', 'pages_read_incomplete' => 'Не вдалося прочитати всі ваші сторінки. Спробуйте ще раз за хвилину.', 'publish_permission_refused' => 'Під час входу відхилено дозвіл, потрібний для публікації. Підключіться знову та надайте всі.', - 'pages_missing_permission' => 'Ми знайшли ваші сторінки, але не дозвіл публікувати на них. Підключіться знову та надайте всі дозволи.', + 'pages_missing_permission' => 'Ми знайшли сторінки, але публікувати нема де. Потрібна роль на самій сторінці та всі дозволи.', 'no_facebook_pages' => 'Сторінок Facebook не знайдено. Ви маєте бути адміністратором хоча б однієї сторінки.', 'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 1b9740c47..bae4ccdc5 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -157,7 +157,7 @@ 'channel_not_found' => '未找到频道。', 'pages_read_incomplete' => '我们没能读取你的全部主页。请稍后再试。', 'publish_permission_refused' => '本次登录拒绝了发布所需的权限。请重新连接并接受全部权限。', - 'pages_missing_permission' => '我们找到了你的主页,但没有发布权限。请重新连接并接受所有权限。', + 'pages_missing_permission' => '我们找到了主页,但没有你能发布的。你需要在主页本身拥有角色,并接受全部权限。', 'no_facebook_pages' => '未找到 Facebook 主页。你至少需要是一个主页的管理员。', 'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', From bf4cadda285e46b33fcc093e8e4e7b2144e80cd4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 10:19:20 -0300 Subject: [PATCH 27/29] fix: one budget for the callback, not one per phase of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk and the Instagram lookups each opened a full meta_page_walk_seconds, on top of the profile touch and the permission read, so the callback's worst case was several times the single bounded budget config/trypost.php advertises. They share one deadline now, taken once and passed down. META_PAGE_WALK_SECONDS joins .env.example. An Instagram account described past that deadline arrived with no handle and no name, and a lone one was then persisted with display_name null — a blank, unidentifiable card. It falls back to the Page's own name. Two docblocks were describing behaviour the code does not have. /me/accounts is the base every other Page is added to, so running out of budget there aborts rather than degrades, and the class now says so instead of promising a partial list. GrantedPermissions justified treating an absent permission as unknown but said nothing about a failed request, which lands in the same place for a different reason. --- .env.example | 1 + .../Controllers/Auth/FacebookController.php | 2 +- .../Auth/InstagramFacebookController.php | 12 +++--- app/Http/Controllers/Auth/MetaController.php | 8 ++++ .../Social/Meta/GrantedPermissions.php | 4 ++ app/Services/Social/Meta/ManagedPages.php | 10 +++-- .../InstagramFacebookControllerTest.php | 40 +++++++++++++++++++ 7 files changed, 67 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 38f5ab766..e931ff4d2 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ SELF_HOSTED=true # Allow more than one connected account per social network in a workspace. # Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true. ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true +META_PAGE_WALK_SECONDS=20 # Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so # every node behind a load balancer shares the same key pair. Use literal \n diff --git a/app/Http/Controllers/Auth/FacebookController.php b/app/Http/Controllers/Auth/FacebookController.php index e0435ccd4..8142f6f8f 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -73,7 +73,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse return $granted; } - $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted); + $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline()); $listed = $this->toPageCards($walk->pages); $pages = ManagedPages::publishable($listed); diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index c3f96af54..e95719261 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -91,7 +91,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse return $granted; } - $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted); + $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline()); $listed = collect($walk->pages) ->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id'))) @@ -220,7 +220,9 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, (string) data_get($pageData, 'ig_id'), [ 'username' => data_get($pageData, 'ig_username'), - 'display_name' => data_get($pageData, 'ig_name') ?? data_get($pageData, 'ig_username'), + 'display_name' => data_get($pageData, 'ig_name') + ?? data_get($pageData, 'ig_username') + ?? data_get($pageData, 'page_name'), 'avatar_url' => $avatarPath, 'access_token' => data_get($pageData, 'page_access_token'), 'refresh_token' => null, @@ -246,18 +248,16 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, */ private function describeInstagramAccounts(array $pages): array { - $deadline = microtime(true) + (int) config('trypost.meta_page_walk_seconds'); - return collect($pages) ->chunk(self::INSTAGRAM_LOOKUPS_PER_ROUND) - ->flatMap(fn (Collection $round) => $this->describeRound($round, $deadline)) + ->flatMap(fn (Collection $round) => $this->describeRound($round, $this->deadline())) ->values() ->all(); } /** * Past the deadline the lookups are skipped rather than dropped: the Page still - * connects, only its Instagram handle and avatar arrive empty. + * connects, falling back to its own name, with no Instagram handle or avatar. * * @param Collection> $pages * @return Collection> diff --git a/app/Http/Controllers/Auth/MetaController.php b/app/Http/Controllers/Auth/MetaController.php index 9185c4add..983f2b4bd 100644 --- a/app/Http/Controllers/Auth/MetaController.php +++ b/app/Http/Controllers/Auth/MetaController.php @@ -17,12 +17,20 @@ abstract class MetaController extends SocialController { protected string $driver = 'facebook'; + private ?float $deadline = null; + /** Graph fields the page walk asks for. */ protected string $pageFields; /** Popup key for "this login has no pages of the kind we want". */ protected string $noPagesKey; + /** When the whole callback must stop reading pages, shared by every phase of it. */ + protected function deadline(): float + { + return $this->deadline ??= microtime(true) + (int) config('trypost.meta_page_walk_seconds'); + } + /** Meta's app review wants to see this called; the answer is unused, so nothing it does can fail the connect. */ protected function touchProfile(string $userToken): void { diff --git a/app/Services/Social/Meta/GrantedPermissions.php b/app/Services/Social/Meta/GrantedPermissions.php index 58c60d0e3..0101aa6a8 100644 --- a/app/Services/Social/Meta/GrantedPermissions.php +++ b/app/Services/Social/Meta/GrantedPermissions.php @@ -14,6 +14,10 @@ * unknown, not a refusal — `/me/permissions` is paginated and Meta does not document * that it echoes scope strings verbatim, and failForMissingScopes() blocks publishing * on a scope missing from this column. + * + * A failed request is unknown in the same way, so it yields the requested list whole. + * That is where this app already stood before it asked Meta at all: a scope declined + * during a Graph outage still surfaces, later, when publishing rejects it. */ class GrantedPermissions { diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 851752d8a..06ce1a000 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -19,7 +19,9 @@ * Portfolio admin — the New Pages Experience norm — gets nothing there. The * portfolio's `owned_pages` and `client_pages` edges are read too and merged by id. * - * Only `/me/accounts` failing is fatal; everything else keeps what arrived. A refusal + * `/me/accounts` is the list everything else is added to, so it is all or nothing: + * failing it — running out of budget included — aborts rather than handing back a base + * this walk cannot vouch for. Everything after it keeps what arrived. A refusal there * is an answer — a Page this login cannot enumerate is one it cannot get a token for, * so it was never connectable. An unknown is not: a throttle, a hiccup, a budget or a * ceiling leaves the walk unable to vouch for itself, and it says so, so no caller @@ -49,8 +51,9 @@ private function __construct( private readonly string $graphApi, private readonly string $userToken, private readonly string $fields, + ?float $deadline, ) { - $this->deadline = microtime(true) + (int) config('trypost.meta_page_walk_seconds'); + $this->deadline = $deadline ?? microtime(true) + (int) config('trypost.meta_page_walk_seconds'); } /** @@ -63,8 +66,9 @@ public static function forUser( string $userToken, string $fields, array $grantedScopes = [self::PORTFOLIO_SCOPE], + ?float $deadline = null, ): ManagedPageList { - return (new self($graphApi, $userToken, $fields))->walk($grantedScopes); + return (new self($graphApi, $userToken, $fields, $deadline))->walk($grantedScopes); } /** diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index a0ad869f2..476519c29 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -761,3 +761,43 @@ expect(SocialAccount::where('platform_user_id', 'ig_1')->sole()->display_name)->toBe('only_a_handle'); }); + +test('instagram via facebook falls back to the page name when the lookups run out of time', function () { + config()->set('trypost.meta_page_walk_seconds', 0); + + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_1', + 'name' => 'The Page Name', + 'access_token' => 'page-token', + 'instagram_business_account' => ['id' => 'ig_1'], + ]]], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback')); + + $account = SocialAccount::where('platform_user_id', 'ig_1')->sole(); + + expect($account->display_name)->toBe('The Page Name') + ->and($account->username)->toBeNull(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_1')); +}); From 9658a0c01883515d100f918e504eb23679b8ef6a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 10:26:06 -0300 Subject: [PATCH 28/29] fix: a Pages throttle on a user token was reading as a refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta's BUC rate-limit table lists code 32 for the Pages API when called with a User token. GraphError did not carry it, because until this branch nothing in the app called a Pages surface that way — the publishers use Page tokens, where the same throttle arrives as 80001. The portfolio walk does: /me/accounts, /me/businesses and both edges are read with the user token straight out of OAuth. So an ordinary throttle came back as code 32, was classified as a confirmed rejection, and the walk concluded this login simply reaches no portfolio Pages — vouching for a list missing all of them and auto-connecting whatever /me/accounts happened to hold. A rate limit was producing the exact silence the complete flag exists to prevent. --- app/Services/Social/Meta/GraphError.php | 7 +++++-- tests/Unit/Social/Meta/ManagedPagesTest.php | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/Services/Social/Meta/GraphError.php b/app/Services/Social/Meta/GraphError.php index 2c23570ab..887d6896e 100644 --- a/app/Services/Social/Meta/GraphError.php +++ b/app/Services/Social/Meta/GraphError.php @@ -25,7 +25,10 @@ * limit". https://developers.facebook.com/docs/graph-api/guides/error-handling/ * - Business Use Case (BUC) Rate Limits (Page/system-user tokens — Facebook * and InstagramFacebook accounts here use Page tokens): code 80001 "Pages - * API", code 80002 "Instagram Platform". Unlike Platform Rate Limits, BUC + * API", code 80002 "Instagram Platform", and code 32 "Pages API with a User + * token" — which the connect flow hits, since the portfolio walk reads + * /me/accounts, /me/businesses and the owned_pages / client_pages edges with + * the user token straight from OAuth. Unlike Platform Rate Limits, BUC * rejections come back as an ordinary HTTP 400, not 429. BUC also covers * several other Meta products (Marketing API, WhatsApp, Messenger, ...) * with their own 80000-series codes — irrelevant here since this app never @@ -48,7 +51,7 @@ class GraphError * Codes Meta uses for rate-limit and other transient upstream problems. * These must never disconnect a still-valid token. */ - private const TRANSIENT_CODES = [1, 2, 4, 17, 80001, 80002]; + private const TRANSIENT_CODES = [1, 2, 4, 17, 32, 80001, 80002]; /** * Whether the given Meta Graph error body is a known rate-limit or diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 20aa2da6b..7beb31315 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -501,3 +501,19 @@ function managedPagesIds(ManagedPageList $walk): array ], 200), ]); })->throws(IncompleteMetaGraphPaginationException::class); + +test('a pages-api throttle on a user token is a throttle, not an answer', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']], + ], 200), + "{$graphApi}/me/businesses*" => Http::response([ + 'error' => ['message' => 'Page request limit reached', 'code' => 32], + ], 400), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); From c10d5ab9e4a0e0498a446b620b0ea5c2ed4ca1fd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 26 Aug 2026 10:39:21 -0300 Subject: [PATCH 29/29] fix: a reconnect no longer loses its handle to a slow Graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persistIdentity updates a reconnected card with whatever it is handed, so a described-with-nulls card overwrote a working account's username and avatar. Skipping the Instagram lookup — which the shared budget now does whenever the walk spent it — produced exactly that card. A lookup that never ran says nothing about a handle the account already has, so those two keys are left out when it did not. Refusing the portfolio index goes back to marking the walk incomplete. I had it that way, reversed it, and this settles it: Meta's Page reference returns access_token for a Page the login holds a role on, and a Page can carry that token on a portfolio edge while /me/accounts omits it — which is this branch's entire premise. So refusing one edge does say those Pages are unreachable, but refusing the index says no edge was read at all, and the Pages behind it may well have been connectable. Silently vouching for a list without them is the original bug. The budget also starts before the walk and now shapes each request's own timeout, so no single call can outlive it by fifteen seconds. composer dev:ssr was a slower alias of composer dev once the SSR process came out of it. --- .../Auth/InstagramFacebookController.php | 16 ++++-- app/Services/Social/Meta/ManagedPages.php | 20 +++++-- composer.json | 5 -- .../InstagramFacebookControllerTest.php | 53 +++++++++++++++++++ tests/Unit/Social/Meta/ManagedPagesTest.php | 4 +- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/Auth/InstagramFacebookController.php b/app/Http/Controllers/Auth/InstagramFacebookController.php index e95719261..d20d32712 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -214,11 +214,14 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, { $avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null; + // A lookup we never made says nothing about the handle a reconnect already has. + $described = (bool) data_get($pageData, 'ig_described'); + SocialAccount::connectIdentity( $workspace, $this->platform, (string) data_get($pageData, 'ig_id'), - [ + array_diff_key([ 'username' => data_get($pageData, 'ig_username'), 'display_name' => data_get($pageData, 'ig_name') ?? data_get($pageData, 'ig_username') @@ -235,7 +238,7 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData, 'page_id' => data_get($pageData, 'page_id'), 'page_name' => data_get($pageData, 'page_name'), ], - ], + ], $described ? [] : ['username' => true, 'avatar_url' => true]), $existingAccount, ); @@ -267,7 +270,9 @@ private function describeRound(Collection $pages, float $deadline): Collection $pages = $pages->values(); $graphApi = $this->graphApi(); - $responses = microtime(true) >= $deadline ? [] : Http::pool(fn (Pool $pool) => $pages + $described = microtime(true) < $deadline; + + $responses = $described ? Http::pool(fn (Pool $pool) => $pages ->map(fn (array $page) => $pool ->timeout(15) ->connectTimeout(5) @@ -275,9 +280,9 @@ private function describeRound(Collection $pages, float $deadline): Collection 'access_token' => data_get($page, 'access_token'), 'fields' => 'username,name,profile_picture_url', ])) - ->all()); + ->all()) : []; - return $pages->map(function (array $page, int $index) use ($responses) { + return $pages->map(function (array $page, int $index) use ($responses, $described) { $response = data_get($responses, $index); $igData = $response instanceof ClientResponse && $response->successful() ? $response->json() : []; @@ -290,6 +295,7 @@ private function describeRound(Collection $pages, float $deadline): Collection 'ig_username' => data_get($igData, 'username'), 'ig_name' => data_get($igData, 'name'), 'ig_picture' => data_get($igData, 'profile_picture_url'), + 'ig_described' => $described && $response instanceof ClientResponse, ]; }); } diff --git a/app/Services/Social/Meta/ManagedPages.php b/app/Services/Social/Meta/ManagedPages.php index 06ce1a000..86958829e 100644 --- a/app/Services/Social/Meta/ManagedPages.php +++ b/app/Services/Social/Meta/ManagedPages.php @@ -121,6 +121,12 @@ private function portfolioPages(): Collection ->flatMap($this->readRound(...)); } + /** No single request may outlive the budget by its own timeout. */ + private function timeout(): int + { + return max(1, min(15, (int) ceil($this->deadline - microtime(true)))); + } + /** Every per-request budget is bounded, but the walk sits in an OAuth callback. */ private function outOfTime(): bool { @@ -146,7 +152,7 @@ private function readRound(Collection $urls): Collection $urls = $urls->values(); $responses = Http::pool(fn (Pool $pool) => $urls - ->map(fn (string $url) => $pool->timeout(15)->connectTimeout(5)->get($url)) + ->map(fn (string $url) => $pool->timeout($this->timeout())->connectTimeout(5)->get($url)) ->all()); return $urls->flatMap(function (string $url, int $index) use ($responses) { @@ -189,7 +195,7 @@ private function rest(string $url, mixed $next): array $this->continuations++; try { - $response = Http::timeout(15)->connectTimeout(5)->get($next); + $response = Http::timeout($this->timeout())->connectTimeout(5)->get($next); } catch (ConnectionException) { $this->complete = false; @@ -214,6 +220,11 @@ private function rest(string $url, mixed $next): array * Reading one page is what bounds the walk: paginating here would let one login * spawn thousands of edge reads. More portfolios than fit is incomplete, not failed. * + * A refusal here is not an answer about any Page. Refusing one edge says those Pages + * are unreadable, and unreadable is unconnectable; refusing the index says no edge + * was ever read, and Meta's own reference has Pages carrying a token on those edges + * while `/me/accounts` omits them, which is the whole reason this walk exists. + * * @return list */ private function businessIds(): array @@ -221,7 +232,7 @@ private function businessIds(): array $url = "{$this->graphApi}/me/businesses"; try { - $response = Http::timeout(15)->connectTimeout(5)->get($url, [ + $response = Http::timeout($this->timeout())->connectTimeout(5)->get($url, [ 'access_token' => $this->userToken, 'limit' => self::MAX_PORTFOLIOS, ]); @@ -232,7 +243,8 @@ private function businessIds(): array } if ($response->failed()) { - $this->note($url, $response); + GraphPaginator::failure($url, $response); + $this->complete = false; return []; } diff --git a/composer.json b/composer.json index 2e8b6aa21..fd8778b7e 100644 --- a/composer.json +++ b/composer.json @@ -105,11 +105,6 @@ "Composer\\Config::disableProcessTimeout", "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], - "dev:ssr": [ - "npm run build:ssr", - "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" --names=server,queue,logs --kill-others" - ], "lint": [ "pint --parallel" ], diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index 476519c29..56d8414e9 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -347,6 +347,7 @@ 'page_access_token' => 'page-token', 'ig_id' => 'ig-new', 'ig_username' => 'mybiz', + 'ig_described' => true, 'ig_name' => 'My Biz', 'ig_picture' => null, ], @@ -406,6 +407,7 @@ 'page_access_token' => 'fresh-token', 'ig_id' => 'ig-old', 'ig_username' => 'mybiz', + 'ig_described' => true, 'ig_name' => 'My Biz', 'ig_picture' => null, ], @@ -464,6 +466,7 @@ 'page_access_token' => 'page-token', 'ig_id' => 'ig-new', 'ig_username' => 'mybiz', + 'ig_described' => true, 'ig_name' => 'My Biz', 'ig_picture' => null, ], @@ -801,3 +804,53 @@ Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_1')); }); + +test('a reconnect keeps the handle it had when the lookup never ran', function () { + config()->set('trypost.meta_page_walk_seconds', 0); + + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::InstagramFacebook, + 'platform_user_id' => 'ig_1', + 'username' => 'the_handle_we_had', + 'avatar_url' => 'avatars/kept.jpg', + ]); + + session([ + 'social_connect_workspace' => $this->workspace->id, + 'social_reconnect_id' => $account->id, + ]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->token = 'test-user-token'; + + Socialite::shouldReceive('driver') + ->with('facebook') + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake([ + "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), + "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), + "{$graphApi}/me/accounts*" => Http::response(['data' => [[ + 'id' => 'page_1', + 'name' => 'The Page', + 'access_token' => 'fresh-token', + 'instagram_business_account' => ['id' => 'ig_1'], + ]]], 200), + ]); + + $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback')); + + $account->refresh(); + + expect($account->username)->toBe('the_handle_we_had') + ->and($account->getRawOriginal('avatar_url'))->toBe('avatars/kept.jpg') + ->and($account->access_token)->toBe('fresh-token'); +}); diff --git a/tests/Unit/Social/Meta/ManagedPagesTest.php b/tests/Unit/Social/Meta/ManagedPagesTest.php index 7beb31315..9f8812695 100644 --- a/tests/Unit/Social/Meta/ManagedPagesTest.php +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -131,7 +131,7 @@ function managedPagesIds(ManagedPageList $walk): array Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses')); }); -test('an app without business_management reaches no portfolio pages, and that is an answer', function () { +test('a refused portfolio index says nothing about the pages behind it', function () { $graphApi = managedPagesGraphApi(); $walk = managedPagesWalk([ @@ -144,7 +144,7 @@ function managedPagesIds(ManagedPageList $walk): array ]); expect(managedPagesIds($walk))->toBe(['page_1']) - ->and($walk->complete)->toBeTrue(); + ->and($walk->complete)->toBeFalse(); }); test('a throttled portfolio index leaves the walk unable to vouch for itself', function () {