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/CLAUDE.md b/CLAUDE.md index 4add397e4..3ec2deb86 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. `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 building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env. + ## 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/Exceptions/Social/IncompleteMetaGraphPaginationException.php b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php index 4d4440ea8..8f070e73d 100644 --- a/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php +++ b/app/Exceptions/Social/IncompleteMetaGraphPaginationException.php @@ -8,14 +8,15 @@ 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 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 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 d77483393..8142f6f8f 100644 --- a/app/Http/Controllers/Auth/FacebookController.php +++ b/app/Http/Controllers/Auth/FacebookController.php @@ -9,11 +9,10 @@ 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; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Uri; use Inertia\Inertia; @@ -21,9 +20,11 @@ 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 string $noPagesKey = 'accounts.popup_callback.no_facebook_pages'; protected SocialPlatform $platform = SocialPlatform::Facebook; @@ -33,6 +34,7 @@ class FacebookController extends SocialController 'pages_read_engagement', 'pages_manage_posts', 'read_insights', + 'business_management', ]; public function connect(Request $request): Response @@ -63,27 +65,29 @@ 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); - $pages = $this->fetchPages($socialUser->token); + $granted = $this->grantedScopes($socialUser->token); + + if ($granted instanceof InertiaResponse) { + return $granted; + } + + $walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline()); + $listed = $this->toPageCards($walk->pages); + $pages = ManagedPages::publishable($listed); if (empty($pages)) { - return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_pages'), $this->platform->value); + return $this->noPagesOnOffer($walk, $listed); } $pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect); if (empty($pages)) { - return $this->noConnectableIdentities($reconnect, 'page_not_found'); + return $this->noConnectableIdentities($reconnect, 'page_not_found', $walk->complete); } - // If only one page, connect directly - if (count($pages) === 1) { + if (count($pages) === 1 && ($walk->complete || $reconnect !== null)) { $page = $pages[0]; $avatarPath = uploadFromUrl(data_get($page, 'picture')); @@ -98,7 +102,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, @@ -119,6 +123,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, ], @@ -192,7 +197,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, @@ -219,17 +224,12 @@ public function select(Request $request): InertiaResponse } } - private function fetchPages(string $userToken): array + /** + * @param array> $pages + * @return list> + */ + private function toPageCards(array $pages): 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, - ], - ); - return collect($pages)->map(fn (array $page) => [ 'id' => data_get($page, 'id'), 'name' => data_get($page, 'name'), @@ -241,6 +241,6 @@ private function fetchPages(string $userToken): array 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 536c7e282..d20d32712 100644 --- a/app/Http/Controllers/Auth/InstagramFacebookController.php +++ b/app/Http/Controllers/Auth/InstagramFacebookController.php @@ -10,11 +10,13 @@ use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\SocialAccount; use App\Models\Workspace; -use App\Services\Social\Meta\GraphPaginator; -use Illuminate\Http\Client\ConnectionException; +use App\Services\Social\Meta\ManagedPages; +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; @@ -23,12 +25,22 @@ 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; + /** + * 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', @@ -71,32 +83,49 @@ 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 = $this->grantedScopes($socialUser->token); + + if ($granted instanceof InertiaResponse) { + return $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'))) + ->values() + ->all(); - $pages = $this->fetchPagesWithInstagram($socialUser->token); + $publishable = ManagedPages::publishable($listed); - if (empty($pages)) { - return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_instagram_pages'), $this->platform->value); + if (empty($publishable)) { + return $this->noPagesOnOffer($walk, $listed); } - $pages = $this->filterConnectableIdentities($workspace, $pages, 'ig_id', $existingAccount); + $connectable = $this->filterConnectableIdentities( + $workspace, + $publishable, + 'instagram_business_account.id', + $existingAccount, + ); - if (empty($pages)) { - return $this->noConnectableIdentities($existingAccount, 'page_not_found'); + if (empty($connectable)) { + return $this->noConnectableIdentities($existingAccount, 'page_not_found', $walk->complete); } - if (count($pages) === 1) { - return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount); + $pages = $this->describeInstagramAccounts($connectable); + + if (count($pages) === 1 && ($walk->complete || $existingAccount !== null)) { + 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 +187,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,22 +206,31 @@ 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; + // 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')), + '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, 'token_expires_at' => null, - 'scopes' => $this->scopes, + 'scopes' => $scopes, 'status' => Status::Connected, 'error_message' => null, 'disconnected_at' => null, @@ -195,58 +238,70 @@ 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, ); return $this->connectedCallback($existingAccount); } - private function fetchPagesWithInstagram(string $userToken): array + /** + * @param array> $pages + * @return list> + */ + private function describeInstagramAccounts(array $pages): array { - $graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api'); - - $pages = GraphPaginator::all("{$graphApi}/me/accounts", [ - 'access_token' => $userToken, - 'fields' => 'id,name,username,picture{url},access_token,instagram_business_account', - 'limit' => 100, - ]); - 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(fn (Collection $round) => $this->describeRound($round, $this->deadline())) ->values() ->all(); } + /** + * Past the deadline the lookups are skipped rather than dropped: the Page still + * connects, falling back to its own name, with no Instagram handle or avatar. + * + * @param Collection> $pages + * @return Collection> + */ + private function describeRound(Collection $pages, float $deadline): Collection + { + $pages = $pages->values(); + $graphApi = $this->graphApi(); + + $described = microtime(true) < $deadline; + + $responses = $described ? 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, $described) { + $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'), + 'ig_described' => $described && $response instanceof ClientResponse, + ]; + }); + } + 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..983f2b4bd --- /dev/null +++ b/app/Http/Controllers/Auth/MetaController.php @@ -0,0 +1,72 @@ +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 + { + rescue(fn () => Http::timeout(5)->connectTimeout(5)->get("{$this->graphApi()}/me", [ + 'fields' => 'id,name', + 'access_token' => $userToken, + ]), report: false); + } + + /** + * 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.publish_permission_refused'), $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 375dae5ce..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()) { @@ -149,13 +155,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 new file mode 100644 index 000000000..0101aa6a8 --- /dev/null +++ b/app/Services/Social/Meta/GrantedPermissions.php @@ -0,0 +1,55 @@ + $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; + } + + $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(); + } +} diff --git a/app/Services/Social/Meta/GraphError.php b/app/Services/Social/Meta/GraphError.php index f81084dd4..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 @@ -73,7 +76,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 b51de03ee..b19a5d879 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 { @@ -30,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; @@ -51,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 { @@ -83,6 +87,12 @@ public static function all(string $url, array $query = []): array return $items->values()->all(); } + /** 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); + } + /** * @throws IncompleteMetaGraphPaginationException */ @@ -93,14 +103,30 @@ 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, so it warns; an unknown stays an error. */ + 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); + return new IncompleteMetaGraphPaginationException($e, transient: $transient); } } diff --git a/app/Services/Social/Meta/ManagedPageList.php b/app/Services/Social/Meta/ManagedPageList.php new file mode 100644 index 000000000..b85699d62 --- /dev/null +++ b/app/Services/Social/Meta/ManagedPageList.php @@ -0,0 +1,14 @@ +> $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 new file mode 100644 index 000000000..86958829e --- /dev/null +++ b/app/Services/Social/Meta/ManagedPages.php @@ -0,0 +1,283 @@ +deadline = $deadline ?? microtime(true) + (int) config('trypost.meta_page_walk_seconds'); + } + + /** + * @param array $grantedScopes + * + * @throws IncompleteMetaGraphPaginationException when `/me/accounts` itself fails + */ + public static function forUser( + string $graphApi, + string $userToken, + string $fields, + array $grantedScopes = [self::PORTFOLIO_SCOPE], + ?float $deadline = null, + ): ManagedPageList { + return (new self($graphApi, $userToken, $fields, $deadline))->walk($grantedScopes); + } + + /** + * 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 + * @return list> + */ + public static function publishable(array $pages): array + { + return collect($pages) + ->filter(fn (array $page) => filled(data_get($page, 'access_token'))) + ->values() + ->all(); + } + + /** + * @param array $grantedScopes + */ + private function walk(array $grantedScopes): ManagedPageList + { + $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()); + } + + 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, + ); + } + + /** + * @return Collection> + */ + private function portfolioPages(): Collection + { + 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) + ->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 + { + if (microtime(true) < $this->deadline) { + return false; + } + + $this->complete = false; + + return true; + } + + /** + * @param Collection $urls + * @return 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($this->timeout())->connectTimeout(5)->get($url)) + ->all()); + + return $urls->flatMap(function (string $url, int $index) use ($responses) { + $response = data_get($responses, $index); + + if (! $response instanceof Response) { + $this->complete = false; + + return []; + } + + if ($response->failed()) { + $this->note($url, $response); + + return []; + } + + return $response->collect('data')->concat($this->rest($url, $response->json('paging.next'))); + }); + } + + /** + * 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 + { + $pages = []; + + while (is_string($next) && filled($next)) { + if ($this->continuations >= self::MAX_CONTINUATIONS || $this->outOfTime() || Uri::of($next)->host() !== Uri::of($url)->host()) { + $this->complete = false; + + break; + } + + $this->continuations++; + + try { + $response = Http::timeout($this->timeout())->connectTimeout(5)->get($next); + } catch (ConnectionException) { + $this->complete = false; + + 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 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 + { + $url = "{$this->graphApi}/me/businesses"; + + try { + $response = Http::timeout($this->timeout())->connectTimeout(5)->get($url, [ + 'access_token' => $this->userToken, + 'limit' => self::MAX_PORTFOLIOS, + ]); + } catch (ConnectionException) { + $this->complete = false; + + return []; + } + + if ($response->failed()) { + GraphPaginator::failure($url, $response); + $this->complete = false; + + return []; + } + + if (filled($response->json('paging.next'))) { + $this->complete = false; + } + + return $response->collect('data') + ->pluck('id') + ->filter() + ->map(strval(...)) + ->take(self::MAX_PORTFOLIOS) + ->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/composer.json b/composer.json index f330b8154..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,#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" - ], "lint": [ "pint --parallel" ], 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/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/lang/ar/accounts.php b/lang/ar/accounts.php index 088618db2..500991a2d 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => 'فشل جلب الملف الشخصي.', '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.', 'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 6bde74a52..f9574718d 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -157,6 +157,9 @@ '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.', + 'publish_permission_refused' => 'Diese Anmeldung hat eine zum Posten nötige Berechtigung abgelehnt. Verbinde erneut und akzeptiere alle.', + '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 1a853c8d4..2b2fffaf7 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => 'Η ανάκτηση του προφίλ απέτυχε.', '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.', 'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 893a43163..ce62dbfdb 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -155,6 +155,9 @@ '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.', + 'publish_permission_refused' => 'This login refused a permission we need to post. Reconnect and accept all of them.', + '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 78dac79bc..f1f273393 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -155,6 +155,9 @@ '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.', + '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 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 e8d1c462f..7ba02b5fe 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -155,6 +155,9 @@ '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.', + '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é 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 54a95b797..20d2deb5f 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -155,6 +155,9 @@ '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.', + 'publish_permission_refused' => 'Questo accesso ha rifiutato una autorizzazione necessaria per pubblicare. Riconnetti accettandole tutte.', + '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 2fbe1753b..23f151420 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => 'プロフィールの取得に失敗しました。', '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 ページが見つかりません。', 'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index e41912efc..48c2c06cc 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => '프로필을 가져오지 못했습니다.', '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 페이지를 찾을 수 없습니다.', 'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index aa69e8afb..835ec17d1 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -155,6 +155,9 @@ '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.', + '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 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 ed8d13e2d..0d5b98a82 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -155,6 +155,9 @@ '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ę.', + 'publish_permission_refused' => 'To logowanie odrzuciło uprawnienie potrzebne do publikowania. Połącz ponownie i zaakceptuj wszystkie.', + '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 69a76812e..cb95d308d 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -155,6 +155,9 @@ '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.', + 'publish_permission_refused' => 'Este login recusou uma permissão necessária para publicar. Reconecte aceitando todas.', + '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 3fbd86903..32023ef44 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => 'Не удалось получить профиль.', '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.', 'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 0e6a14807..dafacd2ff 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -157,6 +157,9 @@ '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.', + '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 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 b7d72f6f4..20b510076 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => 'Не вдалося отримати профіль.', '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.', 'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 7ab84b919..bae4ccdc5 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -155,6 +155,9 @@ 'failed_to_get_profile' => '获取主页信息失败。', '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 主页。', 'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。', diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index a162a88b6..8b3e627db 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -15,6 +15,8 @@ use Laravel\Socialite\Two\User as SocialiteUser; beforeEach(function () { + Http::preventStrayRequests(); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); $this->user->update(['current_workspace_id' => $this->workspace->id]); @@ -55,8 +57,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/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', @@ -104,8 +111,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/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', @@ -142,8 +154,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/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', @@ -182,8 +199,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/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), ]); @@ -211,6 +233,8 @@ $graphApi = config('trypost.platforms.facebook.graph_api'); Http::fake([ + "{$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), ]); @@ -243,6 +267,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -299,6 +325,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -350,6 +378,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -408,8 +438,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/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', @@ -702,8 +737,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/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', @@ -761,8 +801,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/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', @@ -865,13 +910,18 @@ ->with('facebook') ->andReturn($driverMock); + $graphApi = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - '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) @@ -882,3 +932,630 @@ ->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/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), + "{$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/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), + ]); + + $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'))); +}); + +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/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), + "{$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/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' => [ + [ + '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']); +}); + +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 drops a scope meta reports as declined', 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) + ->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 () { + 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'); +}); + +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); +}); + +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.publish_permission_refused'))); + + $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); +}); + +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'))); +}); + +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/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index 5e95070eb..56d8414e9 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; @@ -13,6 +14,8 @@ use Laravel\Socialite\Two\User as SocialiteUser; beforeEach(function () { + Http::preventStrayRequests(); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); $this->user->update(['current_workspace_id' => $this->workspace->id]); @@ -41,6 +44,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -87,7 +92,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(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 () { @@ -112,6 +117,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -172,6 +179,8 @@ $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ + "{$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' => [ @@ -223,6 +232,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -291,6 +302,8 @@ $nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100"; Http::fake([ + "{$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([ @@ -334,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, ], @@ -393,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, ], @@ -451,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, ], @@ -486,10 +502,19 @@ Socialite::shouldReceive('driver') ->with('facebook') - ->andReturn(Mockery::mock(['user' => $socialiteUser])); + ->andReturn(Mockery::mock() + ->shouldReceive('usingGraphVersion')->andReturnSelf() + ->shouldReceive('redirectUrl')->andReturnSelf() + ->shouldReceive('user')->andReturn($socialiteUser) + ->getMock()); + + $graphApi = config('trypost.platforms.instagram-facebook.graph_api'); Http::fake([ - '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', @@ -499,7 +524,7 @@ ], ], ], 200), - 'https://graph.facebook.com/*' => Http::response([ + "{$graphApi}/*" => Http::response([ 'id' => 'shared-ig', 'username' => 'shared', 'name' => 'Shared', @@ -509,10 +534,323 @@ $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) ->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/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), + "{$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, + ]); +}); + +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'))); +}); + +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'); +}); + +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'); +}); + +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')); +}); + +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/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 new file mode 100644 index 000000000..9f8812695 --- /dev/null +++ b/tests/Unit/Social/Meta/ManagedPagesTest.php @@ -0,0 +1,519 @@ +pages)->pluck('id')->all(); +} + +test('business portfolio pages are found when me/accounts is empty', 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' => 'owned-token']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response([ + 'data' => [['id' => 'page_2', 'name' => 'Client', 'access_token' => 'client-token']], + ], 200), + ]); + + 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(); + + $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([ + 'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'portfolio-token']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect($walk->pages)->toHaveCount(1) + ->and(data_get($walk->pages, '0.access_token'))->toBe('role-token'); +}); + +test('a page reached with a token wins over the same page reached without one', function () { + $graphApi = managedPagesGraphApi(); + + $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' => 'Same Page', 'access_token' => 'portfolio-token']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect($walk->pages)->toHaveCount(1) + ->and(data_get($walk->pages, '0.access_token'))->toBe('portfolio-token') + ->and(ManagedPages::publishable($walk->pages))->toHaveCount(1); +}); + +test('every page meta lists is returned, token or not', 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' => 'No Access'], + ['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'], + ], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect(collect($walk->pages)->pluck('id')->sort()->values()->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 meta reports as refusing business_management never touches the portfolio edges', function () { + $graphApi = managedPagesGraphApi(); + + $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 refused portfolio index says nothing about the pages 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', 'code' => 200], + ], 403), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); + +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(); + + $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(); + + $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}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); + +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(); + + managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400), + ]); +})->throws(IncompleteMetaGraphPaginationException::class); + +test('pages spread across several portfolios are all collected', function () { + $graphApi = managedPagesGraphApi(); + + $walk = managedPagesWalk([ + "{$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), + ]); + + 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(); + + $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([ + 'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']], + ], 200), + "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1', 'page_2']) + ->and($walk->complete)->toBeTrue(); +}); + +test('a cursor that fails after the first page keeps that page and admits it is incomplete', 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::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), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); + +test('a portfolio entry without an id is skipped', 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(['data' => [['name' => 'No Id']]], 200), + ]); + + expect($walk->pages)->toHaveCount(1) + ->and($walk->complete)->toBeTrue(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'owned_pages')); +}); + +test('more portfolios than the walk reads is an incomplete walk, not a failed one', 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([ + 'data' => [['id' => 'biz_1']], + 'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"], + ], 200), + "{$graphApi}/biz_1/*_pages*" => Http::response(['data' => []], 200), + ]); + + expect(managedPagesIds($walk))->toBe(['page_1']) + ->and($walk->complete)->toBeFalse(); +}); + +test('the portfolio list is read in one request, never paginated', function () { + $graphApi = managedPagesGraphApi(); + + managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 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), + ]); + + 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(); + + managedPagesWalk([ + "{$graphApi}/me/accounts*" => Http::response(['data' => []], 200), + "{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200), + "{$graphApi}/*_pages*" => Http::response(['data' => []], 200), + ]); + + 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); + } + + $walk = managedPagesWalk($fakes); + + 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(); + + 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), + ]); + + 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(); +}); + +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); + +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(); +});