* Fix Facebook and Instagram-via-Facebook Page connect pagination. Follow Graph API paging.next on /me/accounts so authorized non-first Pages are found and multi-Page accounts get the picker instead of silently connecting the first result. Co-authored-by: Cursor <cursoragent@cursor.com> * Paginate Meta accounts until paging.next is exhausted. Drop the artificial 50-page cap and stop only when there is no next URL, or the same request URL repeats (broken pagination loop). Co-authored-by: Cursor <cursoragent@cursor.com> * Redact tokens in Graph pagination logs and harden test coverage. Cover happy-path and failure cases for Meta /me/accounts pagination, including mid-loop failures, invalid paging.next, and Instagram pages without a linked IG account. Co-authored-by: Cursor <cursoragent@cursor.com> * Fail closed on incomplete Meta accounts pagination. If a later /me/accounts page fails after earlier pages succeeded, throw instead of returning a truncated list that could auto-connect the wrong Page. Also revert the IG detail timeout that could wipe the whole connect list. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify Graph pagination helpers and page fetchers. Bake the first request query into the URL, drop requestKey, and let IncompleteGraphPaginationException bubble from the controllers without catch/rethrow noise. Co-authored-by: Cursor <cursoragent@cursor.com> * Move incomplete pagination exception under Social\Meta. Colocate it with GraphPaginator so the Meta scope is clear from the namespace instead of a generic Social exception name. Co-authored-by: Cursor <cursoragent@cursor.com> * Rename pagination exception to IncompleteMetaGraphPaginationException. Keep it under Exceptions/Social with Meta in the class name instead of moving it into Services. Co-authored-by: Cursor <cursoragent@cursor.com> * Make GraphPaginator results explicit before mapping pages. Assign the paginated accounts to a variable first so the Facebook and Instagram-via-Facebook fetchers read more clearly. Co-authored-by: Cursor <cursoragent@cursor.com> * Build Meta Graph pagination URLs with Laravel Uri. Replace manual http_build_query concatenation with Uri::of()->withQuery(). Co-authored-by: Cursor <cursoragent@cursor.com> * Use Laravel HTTP and Uri helpers in Meta Graph pagination. Prefer response collect/json key access, filled(), and Uri path parsing over manual array and parse_url handling. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify graphVersion using Uri path and str(). Drop basename and native string casts; Uri::path() already yields the Graph API version segment. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop unnecessary str() around graph API config. Uri: :of() already accepts the string returned by config(). Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify GraphPaginator with Laravel helpers. Consolidate failure handling via abort(), and use collect, when, throw_if, and Uri::value() for a shorter pagination loop. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor social OAuth page/channel selection handling. Update selectPage and selectChannel methods in Facebook, Instagram, and YouTube controllers to return popup callbacks instead of redirecting on session expiration or workspace not found. Enhance HandleInertiaRequests middleware to prevent deferring onboarding progress on social OAuth popup routes. Add tests to verify behavior for expired sessions and onboarding progress. * Unify Instagram connect behind one card with a method picker. Hide the Instagram-via-Facebook grid card and offer Instagram Login vs Facebook Pages from a single network entry, matching LinkedIn. Co-authored-by: Cursor <cursoragent@cursor.com> * Move social popup onboarding assertions into connection tests. Cover the deferred-prop popup regression on Facebook, Instagram, and YouTube select routes instead of a synthetic onboarding share check. Co-authored-by: Cursor <cursoragent@cursor.com> * Stop suppressing onboarding defer on all social routes. Override onboardingProgress only in popupCallback so picker pages stay deferred and the close page does not re-hit select after session clear. Co-authored-by: Cursor <cursoragent@cursor.com> * Always open the Instagram method dialog on connect. Drop connectMethods and the single-method OAuth shortcut; the picker always offers both Login and Facebook Pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Filter Instagram dialog options by enabled platforms. Keep always opening the method picker, but only list OAuth entry points that are turned on. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract Instagram connect methods into a dedicated helper. Keep connectableOptions focused on shaping grid options while the enabled OAuth list lives in instagramConnectMethods(). Co-authored-by: Cursor <cursoragent@cursor.com> * Harden Meta Graph pagination and localize Instagram connect copy. Fail closed on Graph request errors and pathological paging, keep Instagram connect going when profile detail lookups time out, and translate the Instagram method dialog strings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4590fc5fd1
commit
173a1e4c61
33 changed files with 1599 additions and 137 deletions
|
|
@ -61,7 +61,7 @@ public function label(): string
|
|||
self::TikTok => 'TikTok',
|
||||
self::YouTube => 'YouTube Shorts',
|
||||
self::Facebook => 'Facebook Page',
|
||||
self::Instagram => 'Instagram (Standalone)',
|
||||
self::Instagram => 'Instagram',
|
||||
self::InstagramFacebook => 'Instagram (Facebook Business)',
|
||||
self::Threads => 'Threads',
|
||||
self::Pinterest => 'Pinterest',
|
||||
|
|
@ -376,37 +376,63 @@ public function isEnabled(): bool
|
|||
|
||||
/**
|
||||
* Whether this platform gets its own "Connect" card in the accounts grid.
|
||||
* LinkedIn company pages are reached through the unified LinkedIn connect
|
||||
* flow's identity picker, never a standalone card. The single LinkedIn card
|
||||
* stands for the whole network, so it shows whenever the profile OR the
|
||||
* company-page capability is enabled (self-hosters may run with only one).
|
||||
* LinkedIn company pages and Instagram-via-Facebook are reached through the
|
||||
* unified network card (identity picker / method dialog), never a standalone
|
||||
* card. That card stands for the whole network, so it shows whenever any
|
||||
* variant capability is enabled (self-hosters may run with only one).
|
||||
*/
|
||||
public function isConnectable(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::LinkedInPage => false,
|
||||
self::LinkedInPage, self::InstagramFacebook => false,
|
||||
self::LinkedIn => self::LinkedIn->isEnabled() || self::LinkedInPage->isEnabled(),
|
||||
self::Instagram => self::Instagram->isEnabled() || self::InstagramFacebook->isEnabled(),
|
||||
default => $this->isEnabled(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth entry points for the Instagram connect dialog. Only enabled methods
|
||||
* are returned so self-hosters who disable one variant do not see that option.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function instagramConnectMethods(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
self::Instagram->isEnabled() ? self::Instagram->value : null,
|
||||
self::InstagramFacebook->isEnabled() ? self::InstagramFacebook->value : null,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connectable platforms shaped for Inertia account/onboarding grids.
|
||||
* Sorted alphabetically by label (ASC, case-insensitive).
|
||||
*
|
||||
* @return list<array{value: string, label: string, color: string, network: string}>
|
||||
* Instagram includes `connect_methods` so the connect dialog only lists
|
||||
* OAuth entry points that are actually enabled (self-hosters may disable one).
|
||||
*
|
||||
* @return list<array{value: string, label: string, color: string, network: string, connect_methods?: list<string>}>
|
||||
*/
|
||||
public static function connectableOptions(): array
|
||||
{
|
||||
return collect(self::cases())
|
||||
->filter(fn (self $platform): bool => $platform->isConnectable())
|
||||
->sortBy(fn (self $platform): string => mb_strtolower($platform->label()))
|
||||
->map(fn (self $platform): array => [
|
||||
'value' => $platform->value,
|
||||
'label' => $platform->label(),
|
||||
'color' => $platform->color(),
|
||||
'network' => $platform->network(),
|
||||
])
|
||||
->map(function (self $platform): array {
|
||||
$option = [
|
||||
'value' => $platform->value,
|
||||
'label' => $platform->label(),
|
||||
'color' => $platform->color(),
|
||||
'network' => $platform->network(),
|
||||
];
|
||||
|
||||
if ($platform === self::Instagram) {
|
||||
$option['connect_methods'] = self::instagramConnectMethods();
|
||||
}
|
||||
|
||||
return $option;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use RuntimeException;
|
||||
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).
|
||||
*/
|
||||
class IncompleteMetaGraphPaginationException extends RuntimeException
|
||||
{
|
||||
public function __construct(?Throwable $previous = null)
|
||||
{
|
||||
parent::__construct('Meta Graph pagination did not complete.', previous: $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,13 @@
|
|||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\Meta\GraphPaginator;
|
||||
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;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
|
@ -139,25 +141,19 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
|
|||
}
|
||||
}
|
||||
|
||||
public function selectPage(Request $request)
|
||||
public function selectPage(Request $request): InertiaResponse
|
||||
{
|
||||
$oauthData = session('facebook_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
session()->flash('flash.banner', __('accounts.flash.session_expired'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace) {
|
||||
session()->flash('flash.banner', __('accounts.flash.workspace_not_found'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
|
||||
}
|
||||
|
||||
$pages = collect(data_get($oauthData, 'pages'))
|
||||
|
|
@ -267,41 +263,26 @@ public function select(Request $request): InertiaResponse
|
|||
|
||||
private function fetchPages(string $userToken): array
|
||||
{
|
||||
try {
|
||||
$response = Http::get(config('trypost.platforms.facebook.graph_api').'/me/accounts', [
|
||||
$pages = GraphPaginator::all(
|
||||
config('trypost.platforms.facebook.graph_api').'/me/accounts',
|
||||
[
|
||||
'access_token' => $userToken,
|
||||
'fields' => 'id,name,username,picture{url},access_token',
|
||||
]);
|
||||
'limit' => 100,
|
||||
],
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Facebook pages fetch failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return collect(data_get($data, 'data', []))->map(fn ($page) => [
|
||||
'id' => data_get($page, 'id'),
|
||||
'name' => data_get($page, 'name'),
|
||||
'username' => data_get($page, 'username', null),
|
||||
'picture' => data_get($page, 'picture.data.url'),
|
||||
'access_token' => data_get($page, 'access_token'),
|
||||
])->toArray();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Facebook pages fetch error', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
return collect($pages)->map(fn (array $page) => [
|
||||
'id' => data_get($page, 'id'),
|
||||
'name' => data_get($page, 'name'),
|
||||
'username' => data_get($page, 'username'),
|
||||
'picture' => data_get($page, 'picture.data.url'),
|
||||
'access_token' => data_get($page, 'access_token'),
|
||||
])->all();
|
||||
}
|
||||
|
||||
private function graphVersion(): string
|
||||
{
|
||||
return basename((string) parse_url((string) config('trypost.platforms.facebook.graph_api'), PHP_URL_PATH));
|
||||
return Uri::of(config('trypost.platforms.facebook.graph_api'))->path();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\Meta\GraphPaginator;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
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;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
|
@ -118,22 +121,19 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
|
|||
}
|
||||
}
|
||||
|
||||
public function selectPage(Request $request)
|
||||
public function selectPage(Request $request): InertiaResponse
|
||||
{
|
||||
$oauthData = session('instagram_facebook_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
session()->flash('flash.banner', 'Session expired. Please try again.');
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
|
||||
}
|
||||
|
||||
$pages = collect(data_get($oauthData, 'pages'))
|
||||
|
|
@ -234,63 +234,49 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
|
|||
|
||||
private function fetchPagesWithInstagram(string $userToken): array
|
||||
{
|
||||
try {
|
||||
$graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api');
|
||||
$graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api');
|
||||
|
||||
$response = Http::get($graphApi.'/me/accounts', [
|
||||
'access_token' => $userToken,
|
||||
'fields' => 'id,name,username,picture{url},access_token,instagram_business_account',
|
||||
]);
|
||||
$pages = GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => $userToken,
|
||||
'fields' => 'id,name,username,picture{url},access_token,instagram_business_account',
|
||||
'limit' => 100,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
Log::error('Instagram via Facebook pages fetch failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
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 = [];
|
||||
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$ig = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/{$igId}", [
|
||||
'access_token' => $token,
|
||||
'fields' => 'username,name,profile_picture_url',
|
||||
]);
|
||||
|
||||
$pages = data_get($response->json(), 'data', []);
|
||||
$results = [];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$igAccountId = data_get($page, 'instagram_business_account.id');
|
||||
|
||||
if (! $igAccountId) {
|
||||
continue;
|
||||
$igData = $ig->successful() ? $ig->json() : [];
|
||||
} catch (ConnectionException) {
|
||||
// Page listing still succeeds; username/avatar may be empty.
|
||||
}
|
||||
|
||||
// Fetch IG account details
|
||||
$igResponse = Http::get("{$graphApi}/{$igAccountId}", [
|
||||
'access_token' => data_get($page, 'access_token'),
|
||||
'fields' => 'username,name,profile_picture_url',
|
||||
]);
|
||||
|
||||
$igData = $igResponse->successful() ? $igResponse->json() : [];
|
||||
|
||||
$results[] = [
|
||||
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' => $igAccountId,
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Instagram via Facebook pages fetch error', ['error' => $e->getMessage()]);
|
||||
|
||||
return [];
|
||||
}
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function graphVersion(): string
|
||||
{
|
||||
return basename((string) parse_url((string) config('trypost.platforms.instagram-facebook.graph_api'), PHP_URL_PATH));
|
||||
return Uri::of(config('trypost.platforms.instagram-facebook.graph_api'))->path();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,10 @@ protected function forgetSocialConnectSession(): void
|
|||
* Render the Inertia page that notifies the opener and closes the connect
|
||||
* popup. Used by both the GET OAuth callbacks (a fresh popup page load) and
|
||||
* the XHR selection submits (an Inertia visit that swaps to this page).
|
||||
*
|
||||
* Always pass `onboardingProgress` as inline false so it overrides the shared
|
||||
* deferred prop: after select the URL is still the select path, and a deferred
|
||||
* reload would re-GET that route with a cleared session.
|
||||
*/
|
||||
protected function popupCallback(bool $success, string $message, ?string $platform = null): Response
|
||||
{
|
||||
|
|
@ -177,6 +181,7 @@ protected function popupCallback(bool $success, string $message, ?string $platfo
|
|||
'success' => $success,
|
||||
'message' => $message,
|
||||
'platform' => $platform,
|
||||
'onboardingProgress' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,25 +124,19 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
|
|||
}
|
||||
}
|
||||
|
||||
public function selectChannel(Request $request)
|
||||
public function selectChannel(Request $request): InertiaResponse
|
||||
{
|
||||
$oauthData = session('youtube_oauth');
|
||||
$workspaceId = session('social_connect_workspace');
|
||||
|
||||
if (! $oauthData || ! $workspaceId) {
|
||||
session()->flash('flash.banner', __('accounts.flash.session_expired'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value);
|
||||
}
|
||||
|
||||
$workspace = Workspace::find($workspaceId);
|
||||
|
||||
if (! $workspace) {
|
||||
session()->flash('flash.banner', __('accounts.flash.workspace_not_found'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value);
|
||||
}
|
||||
|
||||
// Fetch YouTube channels
|
||||
|
|
@ -152,13 +146,10 @@ public function selectChannel(Request $request)
|
|||
$this->forgetSocialConnectSession();
|
||||
session()->forget('youtube_oauth');
|
||||
|
||||
session()->flash('flash.banner', __('accounts.flash.no_youtube_channels'));
|
||||
session()->flash('flash.bannerStyle', 'danger');
|
||||
|
||||
return redirect()->route('app.accounts');
|
||||
return $this->popupCallback(false, __('accounts.popup_callback.no_youtube_channels'), $this->platform->value);
|
||||
}
|
||||
|
||||
return inertia('accounts/YouTubeChannelSelect', [
|
||||
return Inertia::render('accounts/YouTubeChannelSelect', [
|
||||
'workspace' => $workspace,
|
||||
'channels' => $channels,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ public function shareOnce(Request $request): array
|
|||
* Never defer on Passport consent *views*: Inertia deferred props re-request the
|
||||
* same URL, Passport rotates `authToken` on every authorize hit, and approve then
|
||||
* fails with InvalidAuthTokenException against the stale token still on the page.
|
||||
*
|
||||
* Social OAuth popup close pages set `onboardingProgress` to false in
|
||||
* `SocialController::popupCallback()` so a deferred reload does not re-hit the
|
||||
* select route after the connect session was cleared.
|
||||
*/
|
||||
private function onboardingProgress(Request $request, ?User $user): DeferProp|false
|
||||
{
|
||||
|
|
|
|||
106
app/Services/Social/Meta/GraphPaginator.php
Normal file
106
app/Services/Social/Meta/GraphPaginator.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Meta;
|
||||
|
||||
use App\Exceptions\Social\IncompleteMetaGraphPaginationException;
|
||||
use App\Services\Social\TokenRedactor;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Uri;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Collects every item from a paginated Meta Graph API 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.
|
||||
*/
|
||||
class GraphPaginator
|
||||
{
|
||||
/**
|
||||
* Hard ceiling on Graph API pages per edge. Far above any real Page list;
|
||||
* exists only to bound a runaway OAuth callback.
|
||||
*/
|
||||
public const MAX_PAGES = 100;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
* @return list<array<string, mixed>>
|
||||
*
|
||||
* @throws IncompleteMetaGraphPaginationException
|
||||
*/
|
||||
public static function all(string $url, array $query = []): array
|
||||
{
|
||||
$items = collect();
|
||||
$fetched = 0;
|
||||
$seen = [];
|
||||
$allowedHost = Uri::of($url)->host();
|
||||
$next = Uri::of($url)->withQuery($query)->value();
|
||||
|
||||
while (filled($next)) {
|
||||
if ($fetched >= self::MAX_PAGES) {
|
||||
self::abort($next, $fetched, reason: 'Meta Graph pagination stopped: page limit reached');
|
||||
}
|
||||
|
||||
if (isset($seen[$next])) {
|
||||
self::abort($next, $fetched, reason: 'Meta Graph pagination stopped: repeated paging URL');
|
||||
}
|
||||
|
||||
$seen[$next] = true;
|
||||
|
||||
try {
|
||||
$response = Http::timeout(15)->connectTimeout(5)->get($next);
|
||||
} catch (ConnectionException $e) {
|
||||
self::abort($next, $fetched, $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
self::abort($next, $fetched, response: $response);
|
||||
}
|
||||
|
||||
$fetched++;
|
||||
$items = $items->concat($response->collect('data'));
|
||||
$candidate = $response->json('paging.next');
|
||||
|
||||
if (! is_string($candidate) || blank($candidate)) {
|
||||
$next = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Uri::of($candidate)->host() !== $allowedHost) {
|
||||
self::abort($candidate, $fetched, reason: 'Meta Graph pagination stopped: unexpected paging host');
|
||||
}
|
||||
|
||||
$next = $candidate;
|
||||
}
|
||||
|
||||
return $items->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IncompleteMetaGraphPaginationException
|
||||
*/
|
||||
private static function abort(
|
||||
string $url,
|
||||
int $fetched,
|
||||
?Throwable $e = null,
|
||||
?Response $response = null,
|
||||
?string $reason = null,
|
||||
): never {
|
||||
Log::error($reason ?? ($e ? 'Meta Graph pagination connection failed' : 'Meta Graph pagination request failed'), array_filter([
|
||||
'url' => TokenRedactor::redact($url),
|
||||
'error' => $e?->getMessage(),
|
||||
'status' => $response?->status(),
|
||||
'body' => $response ? TokenRedactor::redact($response->body()) : null,
|
||||
'fetched' => $fetched > 0 ? $fetched : null,
|
||||
]));
|
||||
|
||||
throw new IncompleteMetaGraphPaginationException($e);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'اربط حسابك على TikTok',
|
||||
'youtube' => 'اربط قناة على YouTube',
|
||||
'facebook' => 'اربط صفحة على Facebook',
|
||||
'instagram' => 'اربط حساب Instagram احترافيًا',
|
||||
'instagram' => 'اربط عبر Instagram Login أو صفحات Facebook',
|
||||
'instagram-facebook' => 'اربط Instagram عبر صفحة Facebook',
|
||||
'threads' => 'اربط حسابك على Threads',
|
||||
'pinterest' => 'اربط حسابك على Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'اختيار',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'ربط Instagram',
|
||||
'description' => 'اختر طريقة ربط حساب Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'سجّل الدخول بحساب Instagram الاحترافي',
|
||||
'facebook_title' => 'صفحات Facebook',
|
||||
'facebook_description' => 'اربط حساب Instagram مرتبطًا بصفحة Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'اختيار صفحة LinkedIn',
|
||||
'description' => 'اختر الصفحة التي تريد ربطها',
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
'tiktok' => 'Verbinde dein TikTok-Konto',
|
||||
'youtube' => 'Verbinde einen YouTube-Kanal',
|
||||
'facebook' => 'Verbinde eine Facebook-Seite',
|
||||
'instagram' => 'Verbinde ein professionelles Instagram-Konto',
|
||||
'instagram' => 'Verbinde über Instagram Login oder Facebook-Seiten',
|
||||
'instagram-facebook' => 'Verbinde Instagram über eine Facebook-Seite',
|
||||
'threads' => 'Verbinde dein Threads-Konto',
|
||||
'pinterest' => 'Verbinde dein Pinterest-Konto',
|
||||
|
|
@ -98,6 +98,15 @@
|
|||
'choose' => 'Auswählen',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Instagram verbinden',
|
||||
'description' => 'Wähle, wie du dein Instagram-Konto verbinden möchtest',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Melde dich mit deinem professionellen Instagram-Konto an',
|
||||
'facebook_title' => 'Facebook-Seiten',
|
||||
'facebook_description' => 'Verbinde ein Instagram-Konto, das mit einer Facebook-Seite verknüpft ist',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'LinkedIn-Seite auswählen',
|
||||
'description' => 'Wähle die Seite aus, die du verbinden möchtest',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Συνδέστε τον λογαριασμό σας TikTok',
|
||||
'youtube' => 'Συνδέστε ένα κανάλι YouTube',
|
||||
'facebook' => 'Συνδέστε μια σελίδα Facebook',
|
||||
'instagram' => 'Συνδέστε έναν επαγγελματικό λογαριασμό Instagram',
|
||||
'instagram' => 'Συνδέστε μέσω Instagram Login ή σελίδων Facebook',
|
||||
'instagram-facebook' => 'Συνδέστε το Instagram μέσω σελίδας Facebook',
|
||||
'threads' => 'Συνδέστε τον λογαριασμό σας Threads',
|
||||
'pinterest' => 'Συνδέστε τον λογαριασμό σας Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Επιλογή',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Σύνδεση Instagram',
|
||||
'description' => 'Επιλέξτε πώς θέλετε να συνδέσετε τον λογαριασμό Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Συνδεθείτε με τον επαγγελματικό λογαριασμό Instagram',
|
||||
'facebook_title' => 'Σελίδες Facebook',
|
||||
'facebook_description' => 'Συνδέστε έναν λογαριασμό Instagram συνδεδεμένο με σελίδα Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Επιλογή σελίδας LinkedIn',
|
||||
'description' => 'Επιλέξτε ποια σελίδα θέλετε να συνδέσετε',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Connect your TikTok account',
|
||||
'youtube' => 'Connect a YouTube channel',
|
||||
'facebook' => 'Connect a Facebook page',
|
||||
'instagram' => 'Connect an Instagram professional account',
|
||||
'instagram' => 'Connect via Instagram Login or Facebook Pages',
|
||||
'instagram-facebook' => 'Connect Instagram via Facebook page',
|
||||
'threads' => 'Connect your Threads account',
|
||||
'pinterest' => 'Connect your Pinterest account',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Choose',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Connect Instagram',
|
||||
'description' => 'Choose how you want to connect your Instagram account',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Sign in with your Instagram professional account',
|
||||
'facebook_title' => 'Facebook Pages',
|
||||
'facebook_description' => 'Connect an Instagram account linked to a Facebook Page',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Select LinkedIn Page',
|
||||
'description' => 'Choose which page you want to connect',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Conecta tu cuenta de TikTok',
|
||||
'youtube' => 'Conecta un canal de YouTube',
|
||||
'facebook' => 'Conecta una página de Facebook',
|
||||
'instagram' => 'Conecta una cuenta profesional de Instagram',
|
||||
'instagram' => 'Conecta vía Instagram Login o páginas de Facebook',
|
||||
'instagram-facebook' => 'Conecta Instagram vía página de Facebook',
|
||||
'threads' => 'Conecta tu cuenta de Threads',
|
||||
'pinterest' => 'Conecta tu cuenta de Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Elegir',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Conectar Instagram',
|
||||
'description' => 'Elige cómo quieres conectar tu cuenta de Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Inicia sesión con tu cuenta profesional de Instagram',
|
||||
'facebook_title' => 'Páginas de Facebook',
|
||||
'facebook_description' => 'Conecta una cuenta de Instagram vinculada a una página de Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Seleccionar página de LinkedIn',
|
||||
'description' => 'Elige qué página deseas conectar',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Connectez votre compte TikTok',
|
||||
'youtube' => 'Connectez une chaîne YouTube',
|
||||
'facebook' => 'Connectez une page Facebook',
|
||||
'instagram' => 'Connectez un compte professionnel Instagram',
|
||||
'instagram' => 'Connectez via Instagram Login ou des pages Facebook',
|
||||
'instagram-facebook' => 'Connectez Instagram via une page Facebook',
|
||||
'threads' => 'Connectez votre compte Threads',
|
||||
'pinterest' => 'Connectez votre compte Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Choisir',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Connecter Instagram',
|
||||
'description' => 'Choisissez comment connecter votre compte Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Connectez-vous avec votre compte Instagram professionnel',
|
||||
'facebook_title' => 'Pages Facebook',
|
||||
'facebook_description' => 'Connectez un compte Instagram lié à une page Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Sélectionner une page LinkedIn',
|
||||
'description' => 'Choisissez la page que vous souhaitez connecter',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Collega il tuo account TikTok',
|
||||
'youtube' => 'Collega un canale YouTube',
|
||||
'facebook' => 'Collega una pagina Facebook',
|
||||
'instagram' => 'Collega un account professionale Instagram',
|
||||
'instagram' => 'Collega tramite Instagram Login o pagine Facebook',
|
||||
'instagram-facebook' => 'Collega Instagram tramite una pagina Facebook',
|
||||
'threads' => 'Collega il tuo account Threads',
|
||||
'pinterest' => 'Collega il tuo account Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Scegli',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Collega Instagram',
|
||||
'description' => 'Scegli come collegare il tuo account Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Accedi con il tuo account Instagram professionale',
|
||||
'facebook_title' => 'Pagine Facebook',
|
||||
'facebook_description' => 'Collega un account Instagram collegato a una pagina Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Seleziona pagina LinkedIn',
|
||||
'description' => 'Scegli quale pagina vuoi collegare',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'TikTok アカウントを接続',
|
||||
'youtube' => 'YouTube チャンネルを接続',
|
||||
'facebook' => 'Facebook ページを接続',
|
||||
'instagram' => 'Instagram プロアカウントを接続',
|
||||
'instagram' => 'Instagram Login または Facebook ページで接続',
|
||||
'instagram-facebook' => 'Facebook ページ経由で Instagram を接続',
|
||||
'threads' => 'Threads アカウントを接続',
|
||||
'pinterest' => 'Pinterest アカウントを接続',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => '選択',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Instagram を接続',
|
||||
'description' => 'Instagram アカウントの接続方法を選択してください',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Instagram のプロフェッショナルアカウントでサインイン',
|
||||
'facebook_title' => 'Facebook ページ',
|
||||
'facebook_description' => 'Facebook ページに連携された Instagram アカウントを接続',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'LinkedIn ページを選択',
|
||||
'description' => '接続するページを選択してください',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'TikTok 계정을 연결하세요',
|
||||
'youtube' => 'YouTube 채널을 연결하세요',
|
||||
'facebook' => 'Facebook 페이지를 연결하세요',
|
||||
'instagram' => 'Instagram 프로페셔널 계정을 연결하세요',
|
||||
'instagram' => 'Instagram Login 또는 Facebook 페이지로 연결하세요',
|
||||
'instagram-facebook' => 'Facebook 페이지를 통해 Instagram을 연결하세요',
|
||||
'threads' => 'Threads 계정을 연결하세요',
|
||||
'pinterest' => 'Pinterest 계정을 연결하세요',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => '선택',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Instagram 연결',
|
||||
'description' => 'Instagram 계정을 연결할 방법을 선택하세요',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Instagram 프로페셔널 계정으로 로그인하세요',
|
||||
'facebook_title' => 'Facebook 페이지',
|
||||
'facebook_description' => 'Facebook 페이지에 연결된 Instagram 계정을 연결하세요',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'LinkedIn 페이지 선택',
|
||||
'description' => '연결할 페이지를 선택하세요',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Koppel je TikTok-account',
|
||||
'youtube' => 'Koppel een YouTube-kanaal',
|
||||
'facebook' => 'Koppel een Facebook-pagina',
|
||||
'instagram' => 'Koppel een Instagram-professioneel account',
|
||||
'instagram' => 'Koppel via Instagram Login of Facebook-pagina\'s',
|
||||
'instagram-facebook' => 'Koppel Instagram via een Facebook-pagina',
|
||||
'threads' => 'Koppel je Threads-account',
|
||||
'pinterest' => 'Koppel je Pinterest-account',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Kiezen',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Instagram koppelen',
|
||||
'description' => 'Kies hoe je je Instagram-account wilt koppelen',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Log in met je professionele Instagram-account',
|
||||
'facebook_title' => 'Facebook-pagina\'s',
|
||||
'facebook_description' => 'Koppel een Instagram-account dat gekoppeld is aan een Facebook-pagina',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'LinkedIn-pagina selecteren',
|
||||
'description' => 'Kies welke pagina je wilt koppelen',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Połącz swoje konto TikTok',
|
||||
'youtube' => 'Połącz kanał YouTube',
|
||||
'facebook' => 'Połącz stronę na Facebooku',
|
||||
'instagram' => 'Połącz profesjonalne konto Instagram',
|
||||
'instagram' => 'Połącz przez Instagram Login lub strony na Facebooku',
|
||||
'instagram-facebook' => 'Połącz Instagram przez stronę na Facebooku',
|
||||
'threads' => 'Połącz swoje konto Threads',
|
||||
'pinterest' => 'Połącz swoje konto Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Wybierz',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Połącz Instagram',
|
||||
'description' => 'Wybierz sposób połączenia konta Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Zaloguj się kontem profesjonalnym Instagram',
|
||||
'facebook_title' => 'Strony na Facebooku',
|
||||
'facebook_description' => 'Połącz konto Instagram powiązane ze stroną na Facebooku',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Wybierz stronę LinkedIn',
|
||||
'description' => 'Wybierz stronę, którą chcesz połączyć',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Conecte sua conta do TikTok',
|
||||
'youtube' => 'Conecte um canal do YouTube',
|
||||
'facebook' => 'Conecte uma página do Facebook',
|
||||
'instagram' => 'Conecte uma conta profissional do Instagram',
|
||||
'instagram' => 'Conecte via Instagram Login ou Facebook Pages',
|
||||
'instagram-facebook' => 'Conecte Instagram via página do Facebook',
|
||||
'threads' => 'Conecte sua conta do Threads',
|
||||
'pinterest' => 'Conecte sua conta do Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Escolher',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Conectar Instagram',
|
||||
'description' => 'Escolha como você quer conectar sua conta do Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Entre com sua conta profissional do Instagram',
|
||||
'facebook_title' => 'Facebook Pages',
|
||||
'facebook_description' => 'Conecte um Instagram vinculado a uma Página do Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Selecionar Página do LinkedIn',
|
||||
'description' => 'Escolha qual página você deseja conectar',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Подключите аккаунт TikTok',
|
||||
'youtube' => 'Подключите канал YouTube',
|
||||
'facebook' => 'Подключите страницу Facebook',
|
||||
'instagram' => 'Подключите профессиональный аккаунт Instagram',
|
||||
'instagram' => 'Подключите через Instagram Login или страницы Facebook',
|
||||
'instagram-facebook' => 'Подключите Instagram через страницу Facebook',
|
||||
'threads' => 'Подключите аккаунт Threads',
|
||||
'pinterest' => 'Подключите аккаунт Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Выбрать',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Подключить Instagram',
|
||||
'description' => 'Выберите способ подключения аккаунта Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Войдите с профессиональным аккаунтом Instagram',
|
||||
'facebook_title' => 'Страницы Facebook',
|
||||
'facebook_description' => 'Подключите аккаунт Instagram, связанный со страницей Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Выберите страницу LinkedIn',
|
||||
'description' => 'Выберите страницу для подключения',
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
'tiktok' => 'TikTok hesabınızı bağlayın',
|
||||
'youtube' => 'Bir YouTube kanalı bağlayın',
|
||||
'facebook' => 'Bir Facebook sayfası bağlayın',
|
||||
'instagram' => 'Bir Instagram profesyonel hesabı bağlayın',
|
||||
'instagram' => 'Instagram Login veya Facebook Sayfaları ile bağlayın',
|
||||
'instagram-facebook' => 'Facebook sayfası üzerinden Instagram bağlayın',
|
||||
'threads' => 'Threads hesabınızı bağlayın',
|
||||
'pinterest' => 'Pinterest hesabınızı bağlayın',
|
||||
|
|
@ -98,6 +98,15 @@
|
|||
'choose' => 'Seç',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Instagram bağla',
|
||||
'description' => 'Instagram hesabınızı nasıl bağlamak istediğinizi seçin',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Profesyonel Instagram hesabınızla oturum açın',
|
||||
'facebook_title' => 'Facebook Sayfaları',
|
||||
'facebook_description' => 'Bir Facebook Sayfasına bağlı Instagram hesabı bağlayın',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'LinkedIn Sayfası Seç',
|
||||
'description' => 'Bağlamak istediğiniz sayfayı seçin',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => 'Підключіть акаунт TikTok',
|
||||
'youtube' => 'Підключіть канал YouTube',
|
||||
'facebook' => 'Підключіть сторінку Facebook',
|
||||
'instagram' => 'Підключіть професійний акаунт Instagram',
|
||||
'instagram' => 'Підключіть через Instagram Login або сторінки Facebook',
|
||||
'instagram-facebook' => 'Підключіть Instagram через сторінку Facebook',
|
||||
'threads' => 'Підключіть акаунт Threads',
|
||||
'pinterest' => 'Підключіть акаунт Pinterest',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => 'Вибрати',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => 'Підключити Instagram',
|
||||
'description' => 'Оберіть спосіб підключення акаунта Instagram',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => 'Увійдіть за допомогою професійного акаунта Instagram',
|
||||
'facebook_title' => 'Сторінки Facebook',
|
||||
'facebook_description' => 'Підключіть акаунт Instagram, повʼязаний зі сторінкою Facebook',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => 'Виберіть сторінку LinkedIn',
|
||||
'description' => 'Виберіть сторінку, яку хочете підключити',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
'tiktok' => '连接你的 TikTok 账号',
|
||||
'youtube' => '连接一个 YouTube 频道',
|
||||
'facebook' => '连接一个 Facebook 主页',
|
||||
'instagram' => '连接一个 Instagram 专业账号',
|
||||
'instagram' => '通过 Instagram Login 或 Facebook 主页连接',
|
||||
'instagram-facebook' => '通过 Facebook 主页连接 Instagram',
|
||||
'threads' => '连接你的 Threads 账号',
|
||||
'pinterest' => '连接你的 Pinterest 账号',
|
||||
|
|
@ -96,6 +96,15 @@
|
|||
'choose' => '选择',
|
||||
],
|
||||
|
||||
'instagram_connect' => [
|
||||
'title' => '连接 Instagram',
|
||||
'description' => '选择连接 Instagram 账号的方式',
|
||||
'standalone_title' => 'Instagram Login',
|
||||
'standalone_description' => '使用你的 Instagram 专业账号登录',
|
||||
'facebook_title' => 'Facebook 主页',
|
||||
'facebook_description' => '连接关联到 Facebook 主页的 Instagram 账号',
|
||||
],
|
||||
|
||||
'linkedin' => [
|
||||
'title' => '选择 LinkedIn 页面',
|
||||
'description' => '选择你要连接的页面',
|
||||
|
|
|
|||
106
resources/js/components/accounts/InstagramConnectDialog.vue
Normal file
106
resources/js/components/accounts/InstagramConnectDialog.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { IconBrandFacebook, IconBrandInstagram } from '@tabler/icons-vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Platform } from '@/types/platform';
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
methods?: string[];
|
||||
}>(),
|
||||
{
|
||||
methods: () => [Platform.Instagram, Platform.InstagramFacebook],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [method: string];
|
||||
}>();
|
||||
|
||||
const choose = (method: string) => {
|
||||
open.value = false;
|
||||
emit('select', method);
|
||||
};
|
||||
|
||||
const showsStandalone = () => props.methods.includes(Platform.Instagram);
|
||||
const showsFacebook = () => props.methods.includes(Platform.InstagramFacebook);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md" dusk="instagram-connect-dialog">
|
||||
<DialogHeader>
|
||||
<div class="flex items-start gap-3">
|
||||
<img
|
||||
src="/images/accounts/instagram.png"
|
||||
alt="Instagram"
|
||||
class="size-10 rounded-lg"
|
||||
/>
|
||||
<div class="text-left">
|
||||
<DialogTitle>{{
|
||||
$t('accounts.instagram_connect.title')
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>{{
|
||||
$t('accounts.instagram_connect.description')
|
||||
}}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="grid gap-3 py-2">
|
||||
<Button
|
||||
v-if="showsStandalone()"
|
||||
variant="outline"
|
||||
class="h-auto justify-start gap-3 px-4 py-3 text-left whitespace-normal"
|
||||
dusk="instagram-connect-standalone"
|
||||
@click="choose(Platform.Instagram)"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-pink-100 text-pink-600 dark:bg-pink-900 dark:text-pink-300"
|
||||
>
|
||||
<IconBrandInstagram class="size-5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm font-semibold text-foreground">{{
|
||||
$t('accounts.instagram_connect.standalone_title')
|
||||
}}</span>
|
||||
<span class="mt-0.5 block text-xs font-normal text-muted-foreground">{{
|
||||
$t('accounts.instagram_connect.standalone_description')
|
||||
}}</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="showsFacebook()"
|
||||
variant="outline"
|
||||
class="h-auto justify-start gap-3 px-4 py-3 text-left whitespace-normal"
|
||||
dusk="instagram-connect-facebook"
|
||||
@click="choose(Platform.InstagramFacebook)"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-sky-100 text-sky-600 dark:bg-sky-900 dark:text-sky-300"
|
||||
>
|
||||
<IconBrandFacebook class="size-5" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-sm font-semibold text-foreground">{{
|
||||
$t('accounts.instagram_connect.facebook_title')
|
||||
}}</span>
|
||||
<span class="mt-0.5 block text-xs font-normal text-muted-foreground">{{
|
||||
$t('accounts.instagram_connect.facebook_description')
|
||||
}}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
@ -5,6 +5,7 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import InstagramConnectDialog from '@/components/accounts/InstagramConnectDialog.vue';
|
||||
import TelegramConnectDialog from '@/components/accounts/TelegramConnectDialog.vue';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -17,6 +18,7 @@ export interface AvailablePlatform {
|
|||
label: string;
|
||||
color: string;
|
||||
network: string;
|
||||
connect_methods?: string[];
|
||||
}
|
||||
|
||||
export interface ConnectedAccount {
|
||||
|
|
@ -150,6 +152,7 @@ const cardConnection = computed(
|
|||
);
|
||||
|
||||
const telegramOpen = ref(false);
|
||||
const instagramOpen = ref(false);
|
||||
const disconnectModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(
|
||||
null,
|
||||
);
|
||||
|
|
@ -177,12 +180,30 @@ const needsReconnect = (account: ConnectedAccount): boolean =>
|
|||
const connectEntryFor = (platformValue: string): string =>
|
||||
platformValue === Platform.LinkedInPage ? Platform.LinkedIn : platformValue;
|
||||
|
||||
const instagramMethods = computed((): string[] => {
|
||||
const instagram = props.platforms.find(
|
||||
(platform) => platform.value === Platform.Instagram,
|
||||
);
|
||||
|
||||
return (
|
||||
instagram?.connect_methods ?? [
|
||||
Platform.Instagram,
|
||||
Platform.InstagramFacebook,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
const openConnect = (platformValue: string) => {
|
||||
if (platformValue === Platform.Telegram) {
|
||||
telegramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (platformValue === Platform.Instagram) {
|
||||
instagramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
openOAuthPopup(platformValue);
|
||||
};
|
||||
|
||||
|
|
@ -195,7 +216,15 @@ const connectPlatform = (platformValue: string) => {
|
|||
};
|
||||
|
||||
const reconnectAccount = (account: ConnectedAccount) => {
|
||||
openConnect(connectEntryFor(account.platform));
|
||||
const entry = connectEntryFor(account.platform);
|
||||
|
||||
if (entry === Platform.Telegram) {
|
||||
telegramOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect with the same OAuth method — skip the Instagram method picker.
|
||||
openOAuthPopup(entry);
|
||||
};
|
||||
|
||||
const CardState = {
|
||||
|
|
@ -329,6 +358,12 @@ const cardState = computed((): Record<string, CardStateValue> => {
|
|||
|
||||
<TelegramConnectDialog v-model:open="telegramOpen" />
|
||||
|
||||
<InstagramConnectDialog
|
||||
v-model:open="instagramOpen"
|
||||
:methods="instagramMethods"
|
||||
@select="openOAuthPopup"
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
ref="disconnectModal"
|
||||
:title="$t('accounts.disconnect_modal.title')"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Onboarding\ResolveOnboardingStatus;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
|
|
@ -194,6 +195,189 @@
|
|||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'No Facebook Pages found. You need to be an admin of at least one page.'));
|
||||
});
|
||||
|
||||
test('facebook callback fails with error connecting when the first accounts request fails', function () {
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
|
||||
$socialiteUser->token = 'test-user-token';
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('facebook')
|
||||
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
|
||||
|
||||
$graphApi = config('trypost.platforms.facebook.graph_api');
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.error_connecting'))
|
||||
);
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('facebook callback follows accounts pagination and shows picker for pages across 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_1',
|
||||
'name' => 'First Page',
|
||||
'username' => 'first',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'token-1',
|
||||
],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_2',
|
||||
'name' => 'Second Page',
|
||||
'username' => 'second',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'token-2',
|
||||
],
|
||||
],
|
||||
], 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_1')
|
||||
->and(data_get(session('facebook_oauth.pages'), '1.id'))->toBe('page_2');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/me/accounts'));
|
||||
});
|
||||
|
||||
test('facebook callback connects authorized page when first accounts page is empty', 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_desired',
|
||||
'name' => 'Desired Page',
|
||||
'username' => 'desired',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'desired-token',
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$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_desired',
|
||||
'display_name' => 'Desired Page',
|
||||
]);
|
||||
});
|
||||
|
||||
test('facebook callback fails without connecting when accounts pagination is incomplete', 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_1',
|
||||
'name' => 'First Page',
|
||||
'username' => 'first',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'token-1',
|
||||
],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push(['error' => ['message' => 'rate limit']], 400),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('facebook callback fails with expired session', function () {
|
||||
// No session data - simulating expired session
|
||||
|
||||
|
|
@ -294,7 +478,11 @@
|
|||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', true)
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('social_accounts', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -302,6 +490,77 @@
|
|||
'platform_user_id' => 'page_123',
|
||||
'username' => 'myfbpage',
|
||||
]);
|
||||
|
||||
// After connect the session is cleared; PopupCallback sets onboardingProgress
|
||||
// inline so Inertia does not deferred-reload this select URL into /accounts.
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('facebook select page returns popup callback when the session expired', function () {
|
||||
// Popup stays on PopupCallback — never dump /accounts. popupCallback() sets
|
||||
// onboardingProgress inline so Inertia won't deferred-reload this URL.
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('facebook popup callback overrides deferred onboarding progress for mid-activation owners', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
subscribeAccount($this->user->account);
|
||||
|
||||
expect(app(ResolveOnboardingStatus::class)->canShowProgress($this->user->fresh()))->toBeTrue();
|
||||
|
||||
// Picker page may still defer onboarding — that is fine while the OAuth session exists.
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'facebook_oauth' => [
|
||||
'user_token' => 'test-user-token',
|
||||
'user_id' => 'facebook_user_123',
|
||||
'pages' => [
|
||||
[
|
||||
'id' => 'page_123',
|
||||
'name' => 'My Facebook Page',
|
||||
'username' => 'myfbpage',
|
||||
'picture' => null,
|
||||
'access_token' => 'page-access-token',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user->fresh())
|
||||
->get(route('app.social.facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/FacebookPageSelect')
|
||||
->missing('onboardingProgress')
|
||||
->has('pages', 1)
|
||||
);
|
||||
|
||||
// Close/error page must force inline false so Inertia does not re-GET select-page.
|
||||
session()->forget(['facebook_oauth', 'social_connect_workspace']);
|
||||
|
||||
$this->actingAs($this->user->fresh())
|
||||
->get(route('app.social.facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('facebook page selection fails with expired session', function () {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@
|
|||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
|
|
@ -16,6 +19,306 @@
|
|||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
});
|
||||
|
||||
test('instagram-facebook callback follows accounts pagination and shows picker', 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_1',
|
||||
'name' => 'First Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token-1',
|
||||
'instagram_business_account' => ['id' => 'ig_1'],
|
||||
],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_2',
|
||||
'name' => 'Second Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token-2',
|
||||
'instagram_business_account' => ['id' => 'ig_2'],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
"{$graphApi}/ig_1*" => Http::response([
|
||||
'username' => 'ig_one',
|
||||
'name' => 'IG One',
|
||||
'profile_picture_url' => null,
|
||||
], 200),
|
||||
"{$graphApi}/ig_2*" => Http::response([
|
||||
'username' => 'ig_two',
|
||||
'name' => 'IG Two',
|
||||
'profile_picture_url' => null,
|
||||
], 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(2)
|
||||
->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
|
||||
});
|
||||
|
||||
test('instagram-facebook callback connects page when first accounts response is empty', 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_desired',
|
||||
'name' => 'Desired Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token',
|
||||
'instagram_business_account' => ['id' => 'ig_desired'],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
"{$graphApi}/ig_desired*" => Http::response([
|
||||
'username' => 'desired_ig',
|
||||
'name' => 'Desired IG',
|
||||
'profile_picture_url' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$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_desired',
|
||||
'username' => 'desired_ig',
|
||||
]);
|
||||
});
|
||||
|
||||
test('instagram-facebook callback still connects when the instagram profile lookup times out', 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' => 'fb_user', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_1',
|
||||
'name' => 'My Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token',
|
||||
'instagram_business_account' => ['id' => 'ig_1'],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
"{$graphApi}/ig_1*" => Http::failedConnection(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$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_1',
|
||||
]);
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::InstagramFacebook)->value('username'))->toBeNull();
|
||||
});
|
||||
|
||||
test('instagram-facebook callback skips pages without instagram across paginated results', 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_no_ig',
|
||||
'name' => 'No IG Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token-1',
|
||||
],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_with_ig',
|
||||
'name' => 'With IG Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token-2',
|
||||
'instagram_business_account' => ['id' => 'ig_only'],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
"{$graphApi}/ig_only*" => Http::response([
|
||||
'username' => 'only_ig',
|
||||
'name' => 'Only IG',
|
||||
'profile_picture_url' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$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_only',
|
||||
'username' => 'only_ig',
|
||||
]);
|
||||
});
|
||||
|
||||
test('instagram-facebook callback fails without connecting when accounts pagination is incomplete', 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');
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 'page_1',
|
||||
'name' => 'First Page',
|
||||
'picture' => ['data' => ['url' => null]],
|
||||
'access_token' => 'page-token-1',
|
||||
'instagram_business_account' => ['id' => 'ig_1'],
|
||||
],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push(['error' => ['message' => 'rate limit']], 400),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting')));
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::InstagramFacebook)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('instagram-facebook select connects the page in self-hosted mode', function () {
|
||||
config()->set('trypost.self_hosted', true);
|
||||
|
||||
|
|
@ -43,8 +346,11 @@
|
|||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback'));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', true)
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('social_accounts', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -52,6 +358,30 @@
|
|||
'platform_user_id' => 'ig-new',
|
||||
'username' => 'mybiz',
|
||||
]);
|
||||
|
||||
// After connect the session is cleared; PopupCallback sets onboardingProgress
|
||||
// inline so Inertia does not deferred-reload this select URL into /accounts.
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram-facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('instagram-facebook select page returns popup callback when the session expired', function () {
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.instagram-facebook.select-page'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('instagram-facebook select shows network_taken when a standalone instagram is already connected', function () {
|
||||
|
|
|
|||
|
|
@ -283,7 +283,11 @@
|
|||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', true)
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('social_accounts', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
|
|
@ -291,6 +295,30 @@
|
|||
'platform_user_id' => 'UC_channel_123',
|
||||
'username' => 'mychannel',
|
||||
]);
|
||||
|
||||
// After connect the session is cleared; PopupCallback sets onboardingProgress
|
||||
// inline so Inertia does not deferred-reload this select URL into /accounts.
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.youtube.select-channel'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube select channel returns popup callback when the session expired', function () {
|
||||
$this->actingAs($this->user)
|
||||
->get(route('app.social.youtube.select-channel'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('accounts/PopupCallback')
|
||||
->where('success', false)
|
||||
->where('message', __('accounts.popup_callback.session_expired'))
|
||||
->where('onboardingProgress', false)
|
||||
);
|
||||
});
|
||||
|
||||
test('youtube channel selection fails with expired session', function () {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,80 @@
|
|||
);
|
||||
});
|
||||
|
||||
test('accounts index offers a single instagram card and no instagram-facebook card', function () {
|
||||
$response = $this->actingAs($this->user)->get(route('app.accounts'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('accounts/Index', false)
|
||||
->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::Instagram->value)
|
||||
&& ! collect($platforms)->contains('value', Platform::InstagramFacebook->value)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('the instagram card still shows when only facebook business is enabled', function () {
|
||||
config(['trypost.platforms.instagram.enabled' => false]);
|
||||
config(['trypost.platforms.instagram-facebook.enabled' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.accounts'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('accounts/Index', false)
|
||||
->where('platforms', function ($platforms): bool {
|
||||
$instagram = collect($platforms)->firstWhere('value', Platform::Instagram->value);
|
||||
|
||||
return $instagram !== null
|
||||
&& data_get($instagram, 'connect_methods') === [Platform::InstagramFacebook->value];
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('instagram card connect methods omit disabled facebook business entry', function () {
|
||||
config(['trypost.platforms.instagram.enabled' => true]);
|
||||
config(['trypost.platforms.instagram-facebook.enabled' => false]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.accounts'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('accounts/Index', false)
|
||||
->where('platforms', function ($platforms): bool {
|
||||
$instagram = collect($platforms)->firstWhere('value', Platform::Instagram->value);
|
||||
|
||||
return data_get($instagram, 'connect_methods') === [Platform::Instagram->value];
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('the instagram card disappears only when both capabilities are disabled', function () {
|
||||
config(['trypost.platforms.instagram.enabled' => false]);
|
||||
config(['trypost.platforms.instagram-facebook.enabled' => false]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.accounts'));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('accounts/Index', false)
|
||||
->where('platforms', fn ($platforms) => ! collect($platforms)->contains('value', Platform::Instagram->value))
|
||||
);
|
||||
});
|
||||
|
||||
test('a connected instagram-facebook account is still returned so it surfaces under the instagram card', function () {
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::InstagramFacebook,
|
||||
'scopes' => Platform::InstagramFacebook->requiredPublishScopes(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.accounts'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('accounts/Index', false)
|
||||
->where('connectedAccounts.0.platform', Platform::InstagramFacebook->value)
|
||||
->where('connectedAccounts.0.network', 'instagram')
|
||||
);
|
||||
});
|
||||
|
||||
test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
expect(Platform::TikTok->label())->toBe('TikTok');
|
||||
expect(Platform::YouTube->label())->toBe('YouTube Shorts');
|
||||
expect(Platform::Facebook->label())->toBe('Facebook Page');
|
||||
expect(Platform::Instagram->label())->toBe('Instagram (Standalone)');
|
||||
expect(Platform::Instagram->label())->toBe('Instagram');
|
||||
expect(Platform::InstagramFacebook->label())->toBe('Instagram (Facebook Business)');
|
||||
expect(Platform::Threads->label())->toBe('Threads');
|
||||
expect(Platform::Pinterest->label())->toBe('Pinterest');
|
||||
|
|
@ -114,10 +114,10 @@
|
|||
expect(Platform::LinkedIn->isEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
test('only linkedin pages are not directly connectable', function () {
|
||||
test('linkedin pages and instagram facebook are not directly connectable', function () {
|
||||
expect(Platform::LinkedInPage->isConnectable())->toBeFalse();
|
||||
expect(Platform::LinkedIn->isConnectable())->toBeTrue();
|
||||
expect(Platform::InstagramFacebook->isConnectable())->toBeTrue();
|
||||
expect(Platform::InstagramFacebook->isConnectable())->toBeFalse();
|
||||
expect(Platform::Instagram->isConnectable())->toBeTrue();
|
||||
});
|
||||
|
||||
|
|
@ -136,6 +136,21 @@
|
|||
expect(Platform::LinkedInPage->isConnectable())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the instagram card is connectable while either capability is enabled', function () {
|
||||
config(['trypost.platforms.instagram.enabled' => true, 'trypost.platforms.instagram-facebook.enabled' => false]);
|
||||
expect(Platform::Instagram->isConnectable())->toBeTrue();
|
||||
|
||||
config(['trypost.platforms.instagram.enabled' => false, 'trypost.platforms.instagram-facebook.enabled' => true]);
|
||||
expect(Platform::Instagram->isConnectable())->toBeTrue();
|
||||
|
||||
config(['trypost.platforms.instagram.enabled' => false, 'trypost.platforms.instagram-facebook.enabled' => false]);
|
||||
expect(Platform::Instagram->isConnectable())->toBeFalse();
|
||||
|
||||
// Via-Facebook never gets its own card regardless of toggles.
|
||||
config(['trypost.platforms.instagram-facebook.enabled' => true]);
|
||||
expect(Platform::InstagramFacebook->isConnectable())->toBeFalse();
|
||||
});
|
||||
|
||||
test('connectable options are sorted alphabetically by label', function () {
|
||||
$labels = array_column(Platform::connectableOptions(), 'label');
|
||||
|
||||
|
|
@ -144,3 +159,31 @@
|
|||
|
||||
expect($labels)->toBe(array_values($sorted));
|
||||
});
|
||||
|
||||
test('connectable options include a single instagram card and omit the facebook variant', function () {
|
||||
$values = array_column(Platform::connectableOptions(), 'value');
|
||||
|
||||
expect($values)->toContain(Platform::Instagram->value)
|
||||
->and($values)->not->toContain(Platform::InstagramFacebook->value);
|
||||
});
|
||||
|
||||
test('instagram connectable option lists only enabled connect methods', function () {
|
||||
config(['trypost.platforms.instagram.enabled' => true, 'trypost.platforms.instagram-facebook.enabled' => false]);
|
||||
|
||||
expect(Platform::instagramConnectMethods())->toBe([Platform::Instagram->value]);
|
||||
|
||||
$instagram = collect(Platform::connectableOptions())->firstWhere('value', Platform::Instagram->value);
|
||||
|
||||
expect($instagram['connect_methods'])->toBe([Platform::Instagram->value]);
|
||||
|
||||
config(['trypost.platforms.instagram.enabled' => false, 'trypost.platforms.instagram-facebook.enabled' => true]);
|
||||
|
||||
expect(Platform::instagramConnectMethods())->toBe([Platform::InstagramFacebook->value]);
|
||||
|
||||
config(['trypost.platforms.instagram.enabled' => true, 'trypost.platforms.instagram-facebook.enabled' => true]);
|
||||
|
||||
expect(Platform::instagramConnectMethods())->toBe([
|
||||
Platform::Instagram->value,
|
||||
Platform::InstagramFacebook->value,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
$mail = new AccountDisconnected($account);
|
||||
|
||||
expect($mail->envelope()->subject)->toBe('Your Instagram (Standalone) account in My Workspace needs to be reconnected');
|
||||
expect($mail->envelope()->subject)->toBe('Your Instagram account in My Workspace needs to be reconnected');
|
||||
});
|
||||
|
||||
test('account disconnected mail has correct content', function () {
|
||||
|
|
|
|||
322
tests/Unit/Social/Meta/GraphPaginatorTest.php
Normal file
322
tests/Unit/Social/Meta/GraphPaginatorTest.php
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Social\IncompleteMetaGraphPaginationException;
|
||||
use App\Services\Social\Meta\GraphPaginator;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
test('graph paginator returns a single page when there is no paging.next', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::response([
|
||||
'data' => [
|
||||
['id' => 'page_only', 'name' => 'Only Page'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$pages = GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'token',
|
||||
'limit' => 100,
|
||||
]);
|
||||
|
||||
expect($pages)->toHaveCount(1)
|
||||
->and(data_get($pages, '0.id'))->toBe('page_only');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(fn (Request $request) => str_contains($request->url(), 'limit=100')
|
||||
&& str_contains($request->url(), 'access_token=token'));
|
||||
});
|
||||
|
||||
test('graph paginator follows paging.next until exhausted', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=secret-token&after=cursor1&limit=100";
|
||||
$thirdUrl = "{$graphApi}/me/accounts?access_token=secret-token&after=cursor2&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_1', 'name' => 'First'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_2', 'name' => 'Second'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $thirdUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_3', 'name' => 'Third'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$pages = GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'secret-token',
|
||||
'fields' => 'id,name',
|
||||
'limit' => 100,
|
||||
]);
|
||||
|
||||
expect($pages)->toHaveCount(3)
|
||||
->and(data_get($pages, '0.id'))->toBe('page_1')
|
||||
->and(data_get($pages, '1.id'))->toBe('page_2')
|
||||
->and(data_get($pages, '2.id'))->toBe('page_3');
|
||||
|
||||
Http::assertSentCount(3);
|
||||
});
|
||||
|
||||
test('graph paginator collects authorized page when first response is empty', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=token&after=cursor1&limit=100";
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_desired', 'name' => 'Desired Page'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$pages = GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'token',
|
||||
'limit' => 100,
|
||||
]);
|
||||
|
||||
expect($pages)->toHaveCount(1)
|
||||
->and(data_get($pages, '0.id'))->toBe('page_desired');
|
||||
});
|
||||
|
||||
test('graph paginator throws when a later request fails after earlier pages succeeded', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$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) {
|
||||
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]');
|
||||
});
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_1', 'name' => 'First'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200)
|
||||
->push(['error' => ['message' => 'rate limit']], 400),
|
||||
]);
|
||||
|
||||
GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'secret-token',
|
||||
'limit' => 100,
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
|
||||
test('graph paginator throws when the first request fails', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Log::shouldReceive('error')->once()->withArgs(function (string $message) {
|
||||
return $message === 'Meta Graph pagination request failed';
|
||||
});
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/v25.0/me/accounts*' => Http::response(['error' => ['message' => 'fail']], 400),
|
||||
]);
|
||||
|
||||
GraphPaginator::all('https://graph.facebook.com/v25.0/me/accounts', [
|
||||
'access_token' => 'token',
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
|
||||
test('graph paginator throws when the first request cannot connect', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Log::shouldReceive('error')->once()->withArgs(function (string $message) {
|
||||
return $message === 'Meta Graph pagination connection failed';
|
||||
});
|
||||
|
||||
Http::fake([
|
||||
'https://graph.facebook.com/v25.0/me/accounts*' => Http::failedConnection(),
|
||||
]);
|
||||
|
||||
GraphPaginator::all('https://graph.facebook.com/v25.0/me/accounts', [
|
||||
'access_token' => 'token',
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
test('graph paginator throws when a later request cannot connect', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
$nextUrl = "{$graphApi}/me/accounts?access_token=secret-token&after=cursor1&limit=100";
|
||||
$requestCount = 0;
|
||||
|
||||
Log::shouldReceive('error')->once()->withArgs(function (string $message, array $context) {
|
||||
return $message === 'Meta Graph pagination connection failed'
|
||||
&& str_contains((string) data_get($context, 'url'), 'access_token=[REDACTED]');
|
||||
});
|
||||
|
||||
Http::fake(function () use (&$requestCount, $nextUrl) {
|
||||
$requestCount++;
|
||||
|
||||
if ($requestCount === 1) {
|
||||
return Http::response([
|
||||
'data' => [
|
||||
['id' => 'page_1'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $nextUrl,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::failedConnection();
|
||||
});
|
||||
|
||||
GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'secret-token',
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
|
||||
test('graph paginator stops when paging.next is not a usable string', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::response([
|
||||
'data' => [
|
||||
['id' => 'page_1'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => ['broken' => true],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$pages = GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'token',
|
||||
]);
|
||||
|
||||
expect($pages)->toHaveCount(1);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('graph paginator throws when paging.next repeats after pages were fetched', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
$loopUrl = "{$graphApi}/me/accounts?access_token=secret-token&after=cursor&limit=100";
|
||||
|
||||
Log::shouldReceive('error')->once()->withArgs(function (string $message, array $context) {
|
||||
return $message === 'Meta Graph pagination stopped: repeated paging URL'
|
||||
&& ! str_contains((string) data_get($context, 'url'), 'secret-token')
|
||||
&& str_contains((string) data_get($context, 'url'), 'access_token=[REDACTED]');
|
||||
});
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::sequence()
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_1'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $loopUrl,
|
||||
],
|
||||
], 200)
|
||||
->push([
|
||||
'data' => [
|
||||
['id' => 'page_2'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => $loopUrl,
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'token',
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
|
||||
test('graph paginator throws when paging.next points to another host', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
|
||||
Log::shouldReceive('error')->once()->withArgs(function (string $message) {
|
||||
return $message === 'Meta Graph pagination stopped: unexpected paging host';
|
||||
});
|
||||
|
||||
Http::fake([
|
||||
"{$graphApi}/me/accounts*" => Http::response([
|
||||
'data' => [
|
||||
['id' => 'page_1'],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => 'https://evil.example/me/accounts?access_token=secret-token',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'token',
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
|
||||
test('graph paginator throws when the safety page ceiling is reached', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$graphApi = 'https://graph.facebook.com/v25.0';
|
||||
$requestCount = 0;
|
||||
|
||||
Log::shouldReceive('error')->once()->withArgs(function (string $message) {
|
||||
return $message === 'Meta Graph pagination stopped: page limit reached';
|
||||
});
|
||||
|
||||
Http::fake(function () use (&$requestCount, $graphApi) {
|
||||
$requestCount++;
|
||||
$after = $requestCount;
|
||||
|
||||
return Http::response([
|
||||
'data' => [
|
||||
['id' => "page_{$requestCount}"],
|
||||
],
|
||||
'paging' => [
|
||||
'next' => "{$graphApi}/me/accounts?access_token=token&after={$after}",
|
||||
],
|
||||
], 200);
|
||||
});
|
||||
|
||||
GraphPaginator::all("{$graphApi}/me/accounts", [
|
||||
'access_token' => 'token',
|
||||
]);
|
||||
})->throws(IncompleteMetaGraphPaginationException::class);
|
||||
Loading…
Reference in a new issue