Connect the Pages a login only reaches through a Business Portfolio (#301)

* fix: Facebook Page fetch missing New Pages Experience pages

/me/accounts silently omits Pages that live under Meta's newer
"New Pages Experience" / Business Portfolio model, even when the
token's granular scopes show the Page was explicitly granted -
confirmed via Meta's own Access Token Debugger against a live
account whose Page returned zero results from /me/accounts but
resolved fine when queried directly by ID.

Falls back through Business Manager's owned_pages/client_pages
(via the existing business_management scope) when /me/accounts
comes back empty, so Pages under that model are still found.

* fix: Instagram-via-Facebook has the same New Pages Experience gap

Same root cause and fix as the FacebookController fetchPages()
fallback - the Page/Instagram-linked-Page lookup goes through the
same /me/accounts call and is subject to the same Meta-side gap.

* refactor: one place finds every Page a Meta login can publish to

Both controllers walked /me/accounts and, when it came back empty, the
Business Portfolio edges behind it. ManagedPages now owns that walk for
the Facebook and Instagram-via-Facebook flows alike.

Three behaviour changes come with it:

The portfolio edges are read on every connect, not only when /me/accounts
is empty, and merged by Page id. Someone holding one Page by a classic
role and the rest through a portfolio was auto-connected to that single
Page and never offered the others.

/me/businesses is read only once /me/permissions confirms the login
granted business_management, and a failure anywhere along the portfolio
walk leaves the /me/accounts list standing. It used to escape into the
callback's catch, turning "no pages" into "could not connect" for every
login without the scope.

Pages the login cannot get an access_token for are dropped. Connecting
one produces an account that cannot publish.

* test: pin the Page a login only reaches through a portfolio

Covers the merge with /me/accounts, the access_token filter, the
business_management gate, and a failing portfolio edge leaving the
/me/accounts list intact — plus the connect flow end to end on both
Meta platforms.

The Instagram-via-Facebook request count moves from five to six for
the /me/permissions check.

* refactor: read the portfolio edges without asking permission first

The /me/permissions check saved a rejected /me/businesses call on logins
without business_management, at the cost of running a path nobody had
verified against a live account. The edges already fail soft, so the
check bought log tidiness and nothing else.

* test: cover the portfolio walk's remaining shapes

The multi-Page selection flow behind a portfolio — the case #292 asked a
maintainer to check — plus pages spread across two portfolios, a
paginated edge, a portfolio entry with no id, and a portfolio page
merging with one /me/accounts already returned.

* test: stop the Meta connect tests from calling Graph for real

Http::fake only stubs the URLs it is given; anything else goes out over
the network. These files stubbed /me/accounts and left /me — and now
/me/businesses — unstubbed, so the suite was issuing live requests to
graph.facebook.com on every run. They came back 400 and the code under
test swallowed them, so nothing ever went red while the assertions were
measuring Meta's answer instead of the fixture's.

Every Graph call the connect flow makes is stubbed now, and the files
prevent stray requests so a missing one fails loudly. Inertia's SSR
endpoint is allowed through; it is not what these tests are about.

* fix: tell a denied portfolio edge apart from a throttled one

GraphPaginator throws so no caller reads a failed fetch as an empty list
and auto-connects whatever arrived first. Swallowing that exception on
the portfolio edges gave the invariant away: a 429 on owned_pages left
the merged list holding only the /me/accounts page, and the callback
connected it with no picker.

The exception now carries whether the failure was transient, classified
by GraphError, which already owns Meta's rate-limit and transient code
table. A denied permission reads as "this login reaches no portfolio
pages"; a throttle, a 5xx or a truncated walk is raised.

The walk also stops at MAX_PORTFOLIOS and logs what it skipped. Each
portfolio costs two more paginated edges inside a synchronous OAuth
callback, and nothing bounded that loop.

* fix: three ways the portfolio walk misread what Meta returned

Concurrent Instagram lookups. The Instagram description ran one request
per Page, in sequence, at a 15s timeout each. That list used to be the
Pages someone holds a role on — a handful. It is now the union with
every portfolio's owned_pages and client_pages, so a portfolio holding
hundreds of Pages serialised the OAuth callback past any gateway
timeout, for exactly the accounts the portfolio walk exists to reach.
Meta's ids= batching is no help: each Page carries its own access token
and one call takes one token. The lookups run in concurrent rounds.

A Page without a token is not a Page you don't have. Meta lets someone
decline pages_read_engagement on its per-permission toggles and still
lists the Page, without an access_token. Dropping it inside the walk
left the caller saying "no Pages found, you need to be an admin of at
least one" to an admin. ManagedPages returns everything Meta listed and
publishable() separates what can be posted to, so the callbacks can
tell the two apart and say which one happened.

Stored scopes are what Meta granted. The scope list was written to the
account's scopes column straight from the request, claiming access the
login may have refused — business_management above all, which needs
Advanced Access and is declined by default without it. It now comes
from /me/permissions, falling back to the request when Meta cannot be
asked.

* fix: only drop a scope Meta says was refused

PublishToSocialPlatform::failForMissingScopes() blocks a post when a
platform's required publish scope is absent from the account's scopes
column, so writing that column from /me/permissions can dead-end an
account. Meta does not document that the endpoint echoes scope strings
verbatim, and the edge is paginated, so a scope it never mentions is
unknown rather than refused and stays. Only declined and expired drop.

* fix: keep the portfolio walk honest and cheap

Raise instead of truncating. The ceiling logged a warning and returned
whatever fit, which is the one thing this module refuses to do
everywhere else: if the walk cannot finish, the real list is unknown,
and a truncated list holding exactly one Page would have been
auto-connected without ever showing the picker. It now raises, and the
ceiling rises to GraphPaginator::MAX_PAGES' 100 since the walk no
longer pays for it serially.

Read the edges concurrently. Up to two paginated edges per portfolio ran
back to back inside the OAuth callback. They run in rounds now; a URL
that does not come back cleanly still goes through GraphPaginator, which
owns the single place that logs a Graph failure and decides whether it
is a rejection or an unknown.

Prefer the record that carries a token. Merging kept whichever copy of a
Page id arrived first, and /me/accounts always arrives first — so a Page
listed there without a token buried the portfolio copy that had one, and
the login was told its permission was missing for a Page it could reach.

Describe only the Instagram accounts that survive. The per-Page lookup
ran before filterConnectableIdentities discarded them, spending a
BUC-rate-limited call on every Page only to throw the answer away. The
filter reads instagram_business_account.id straight off the raw Page, so
it needs no lookup to run first.

Two Instagram tests mocked Socialite without usingGraphVersion, so the
callback threw, the generic catch answered, and asserting only
success=false passed on the error path instead of the one under test.

* test: pin that pages survive past the first pooled round

The concurrency test drove 30 portfolios with every edge empty, so the
merge across rounds was never exercised with data in it.

* fix: keep the paging-host guard on the pooled edge walk

GraphPaginator refuses to follow a paging.next that points off the host
the walk started from, so a tampered response cannot carry the access
token somewhere else. Reading the first page of each edge through the
pool and handing its paging.next straight back to GraphPaginator made
that URL the *start* of a new walk, which is the one URL the guard
trusts implicitly — so the first hop went unchecked.

The host is compared before the hand-off now, and a mismatch re-walks
the edge from the beginning so GraphPaginator's own guard is what
refuses it, with its logging.

* fix: stop a cut-short walk from passing for a complete one

optional() was written for "this edge may be forbidden" and answers a
rejection with an empty list. Following paging.next through it gave a
rejected cursor the same answer: page one of a 250-Page portfolio came
back and the rest was dropped, and a single connectable Page in that
fragment would have been auto-connected with no picker. The same hole
sat on /me/businesses, where GraphPaginator is all-or-nothing — a
failure on page two threw away the portfolios page one had already
listed, degrading the connect back to /me/accounts alone in silence.

The exception now carries how many pages arrived. Only a rejection on
the very first request reads as "this edge is not readable"; anything
after that is a fragment and raises. Cursors skip optional() entirely.

A login Meta reports as refusing business_management also stops walking
the edges at all. The controllers already read /me/permissions for the
scopes column, so the answer costs nothing, and the walk was otherwise
spending a request on a certain 403 — and logging it at error level —
on every successful connect by such a login.

An Instagram account with an empty Name connected as display_name null:
data_get's default only fires on an absent key, and describeRound always
writes the key.

* test: pin reconnecting a card only the portfolio still reaches

A card whose Page moved behind a portfolio is the reconnect shape of the
bug this branch fixes, and nothing covered it: the walk has to find the
Page, and filterConnectableIdentities has to keep the original card
rather than offering the portfolio's other Pages.

* fix: an unreadable portfolio must not deny the pages that were readable

The portfolio edges are additive, but every failure in them was raised
and the callback's generic catch turned it into "error connecting" — so
one throttled edge among sixty denied a login the Pages /me/accounts had
already returned, and each retry burned more of the quota that caused it.
Only /me/accounts failing is fatal now; everything else marks the walk
incomplete and keeps what arrived.

What the raise was protecting is kept where it belongs: a lone Page is
only taken without asking when the walk saw everything, or when a
reconnect has already pinned which Page is wanted. Otherwise the picker
opens, and the login can see for itself that its Page is not there.

The ceiling stops pretending. It compared the count after walking every
page of /me/businesses — up to ten thousand ids — so the runaway it
existed to bound had already happened. One request, one page, and more
portfolios than that is an incomplete walk rather than a failed one.

A pooled edge that fails is classified where it lands instead of being
re-fetched, halving the cost of the common client_pages rejection, and
GraphPaginator logs a confirmed rejection at warning: it is Meta
answering the question, not something going wrong.

A login that declined the permission its platform needs to publish is
refused at connect. Meta issues a Page token off pages_show_list, so
declining pages_manage_posts still produced a green account whose every
scheduled post was then hard-failed by failForMissingScopes.

Test fakes address Graph through the config rather than a literal host,
which is what CLAUDE.md asks for and what the newer tests already did.

* fix: an incomplete walk must not answer as if it were sure

Marking the walk incomplete stopped the auto-connect, but every dead end
after it still gave a definitive answer. A login whose only Pages sit
behind a throttled portfolio was told "no Facebook Pages found, you need
to be an admin of at least one" — the exact sentence this branch exists
to stop showing to admins, now arriving for a different reason. The
already-connected and missing-permission answers were equally sure of
themselves.

When the walk could not see everything and there is nothing to offer,
it says so and asks for a retry.

* fix: stop every Inertia test from calling an SSR server

inertia.ssr.enabled defaulted to true and nothing in phpunit.xml turned
it off, so every test rendering an Inertia page issued a real request to
the SSR endpoint. The project does not use SSR, so those calls only ever
failed and fell back to client rendering — quietly, on every run.

Defaulting it off is what the project already assumed, and it retires
the allowStrayRequests hole the Meta connect tests were carrying to work
around it. INERTIA_SSR_ENABLED still turns it back on.

* fix: a taken slot is a fact, not a guess about the listing

Routing every short listing to "try again in a moment" swallowed
network_taken: a workspace that already holds its one Facebook account
was told to retry, forever, whenever a portfolio edge was throttled. That
answer comes from our own rows and does not depend on how far the walk
got. all_connected and page_not_found do, and still yield.

Also: an off-host cursor now stops the edge instead of re-reading page
one, which cost a request and could follow an on-host cursor on the
retry, quietly undoing the guard. Cursor follow-ups are budgeted, since
they cannot be pooled and were the one unbounded serial path left. The
exception's fetched count lost its last reader two commits ago and is
gone. Comments trimmed throughout.

* fix: bound the cursor walk by requests, and keep what it read

MAX_CONTINUATIONS counted edges, not requests: each one then handed off
to GraphPaginator, which follows up to a hundred more pages by itself.
The budget the docblock promised was fifty times larger than it claimed.
Cursors are now followed one budgeted request at a time, so the count
means what it says, and pages already read survive a cut-off instead of
being thrown away with the exception.

A refused /me/businesses is no longer read as "this login has no
portfolios". For a single edge a rejection answers the question; for the
index of edges it means we could not look — and answering complete there
auto-connected the one /me/accounts page while hiding every portfolio
Page, which is this branch's own bug wearing a different hat.

SSR goes back to its shipped default. Turning it off in config to quiet
the test suite would have disabled it wherever it is actually started —
docker/Dockerfile builds the bundle. phpunit.xml carries the switch now,
next to PULSE, TELESCOPE and NIGHTWATCH, and CLAUDE.md records why.

* refactor: one Meta connect flow instead of two kept in step by hand

The Facebook and Instagram-via-Facebook callbacks ran the same twenty-five
lines: the profile touch Meta's review wants, the granted-scope read and
the publish-scope refusal, the page walk, and the answer for a walk with
nothing to offer. They only matched because both were edited side by side,
every round, which is a guarantee nobody should be making by hand.

graphApi() moves to SocialController and reads the host by platform value,
so it serves every network rather than the two that had copied it, and
graphVersion() derives from it instead of reading config a second time.

select() stays as it is. The two differ in the middle — different identity
keys, different connect shapes — and folding them would be abstraction for
its own sake.

* fix: default Inertia SSR off, where this project already stands

Nothing in the repo starts an SSR process, so the shipped default was
describing a setup that does not exist. With it off the test env needs no
override of its own, and CLAUDE.md records that turning it on means
starting the process, not just flipping the env.

* fix: one rule for a refused portfolio, and a clock on the walk

Last round I made a refused /me/businesses mark the walk incomplete, on
the argument that refusing the index means "we could not look". That was
wrong in the case that matters most: an app without Advanced Access for
business_management gets that refusal on every single connect, so every
login on such an install lost auto-connect and every login without Pages
was told to retry forever. Self-hosted in Live mode is exactly that.

The rule that holds everywhere: a Page this login cannot enumerate is a
Page it cannot get a token for, so it was never connectable, and the list
of connectable Pages is complete. Only an unknown — a throttle, a hiccup,
a budget or a ceiling — leaves the walk unable to vouch for itself. Index
and edge now answer the same way, which is also what makes the two
readable together.

The per-request budgets were each bounded while their sum was not: ten
pooled rounds plus twenty-five cursor requests can outlive nginx's
fastcgi_read_timeout of 120s. The walk now carries a deadline and returns
what it has.

touchProfile exists only because Meta's review wants the call. It had no
timeout and no guard, so a hung /me could stall the callback to the
gateway timeout or fail a connect outright, over a response nobody reads.

* docs: the walk's contract changed under its own docblock

It still said any failure marks the walk incomplete, which stopped being
true when a refusal became an answer. A docblock describing an invariant
the code no longer holds is how this branch got two of its bugs.

* fix: put the whole callback inside the budget it advertises

meta_page_walk_seconds bounded the portfolio half of the walk and nothing
else. /me/accounts could paginate a hundred pages at fifteen seconds
each, and the Instagram lookups pooled in rounds that were themselves
serial — a portfolio with three hundred linked Pages is fifteen rounds,
after the walk had already spent its own budget. Both honour the deadline
now. The lookups skip rather than drop: the Page still connects, only its
handle and avatar arrive empty. The first request is always made; the
budget bounds what comes after it.

A Graph body that is valid JSON but not an object — a proxy answering
"throttled" — reached GraphError::isTransient, whose parameter is ?array,
and under strict_types raised a TypeError. That is an Error, so it walked
past both callbacks' catch(\Exception) and 500'd the popup instead of
showing a message.

Refusing a login before any listing has happened no longer borrows the
wording for "we found your Pages but not the permission to post to them".

composer run dev no longer starts an SSR process for SSR that is off, and
CLAUDE.md no longer claims nothing in the repo starts one, which
composer.json contradicted.

* fix: say what actually gets a Page token, per Meta's own reference

The Page node reference is explicit: access_token is "only returned if
the User making the request has a role (other than Live Contributor) on
the Page". Being an admin of the portfolio that owns a Page lists it but
does not grant that role, so the walk can surface Pages this login will
never get a token for.

The popup told those users to reconnect and accept every permission,
which cannot produce a Page role and so could never work. It now names
the role as well.

I rejected this in review on the grounds that a portfolio Page had been
published to successfully in the wild. That proved a token comes back
when the login holds a role, not that one always does.

* fix: one budget for the callback, not one per phase of it

The walk and the Instagram lookups each opened a full
meta_page_walk_seconds, on top of the profile touch and the permission
read, so the callback's worst case was several times the single bounded
budget config/trypost.php advertises. They share one deadline now, taken
once and passed down. META_PAGE_WALK_SECONDS joins .env.example.

An Instagram account described past that deadline arrived with no handle
and no name, and a lone one was then persisted with display_name null —
a blank, unidentifiable card. It falls back to the Page's own name.

Two docblocks were describing behaviour the code does not have.
/me/accounts is the base every other Page is added to, so running out of
budget there aborts rather than degrades, and the class now says so
instead of promising a partial list. GrantedPermissions justified
treating an absent permission as unknown but said nothing about a failed
request, which lands in the same place for a different reason.

* fix: a Pages throttle on a user token was reading as a refusal

Meta's BUC rate-limit table lists code 32 for the Pages API when called
with a User token. GraphError did not carry it, because until this branch
nothing in the app called a Pages surface that way — the publishers use
Page tokens, where the same throttle arrives as 80001.

The portfolio walk does: /me/accounts, /me/businesses and both edges are
read with the user token straight out of OAuth. So an ordinary throttle
came back as code 32, was classified as a confirmed rejection, and the
walk concluded this login simply reaches no portfolio Pages — vouching
for a list missing all of them and auto-connecting whatever /me/accounts
happened to hold. A rate limit was producing the exact silence the
complete flag exists to prevent.

* fix: a reconnect no longer loses its handle to a slow Graph

persistIdentity updates a reconnected card with whatever it is handed,
so a described-with-nulls card overwrote a working account's username
and avatar. Skipping the Instagram lookup — which the shared budget now
does whenever the walk spent it — produced exactly that card. A lookup
that never ran says nothing about a handle the account already has, so
those two keys are left out when it did not.

Refusing the portfolio index goes back to marking the walk incomplete. I
had it that way, reversed it, and this settles it: Meta's Page reference
returns access_token for a Page the login holds a role on, and a Page can
carry that token on a portfolio edge while /me/accounts omits it — which
is this branch's entire premise. So refusing one edge does say those
Pages are unreachable, but refusing the index says no edge was read at
all, and the Pages behind it may well have been connectable. Silently
vouching for a list without them is the original bug.

The budget also starts before the walk and now shapes each request's own
timeout, so no single call can outlive it by fifteen seconds.

composer dev:ssr was a slower alias of composer dev once the SSR process
came out of it.

---------

Co-authored-by: StoriaJames <james@storia.tech>
This commit is contained in:
Paulo Castellano 2026-08-26 10:42:59 -03:00 committed by GitHub
parent 91c3d86d86
commit 02e44b9785
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2246 additions and 131 deletions

View file

@ -14,6 +14,7 @@ SELF_HOSTED=true
# Allow more than one connected account per social network in a workspace.
# Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true.
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true
META_PAGE_WALK_SECONDS=20
# Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so
# every node behind a load balancer shares the same key pair. Use literal \n

View file

@ -208,6 +208,12 @@ ## Frontend (Vue/TypeScript)
- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.
## Inertia SSR
- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false` and nothing in the repo sets `INERTIA_SSR_ENABLED`.
- Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs.
- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env.
## Dialogs
- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.

View file

@ -8,14 +8,15 @@
use Throwable;
/**
* Thrown when a Meta Graph edge could not be fully fetched the first page
* failed, a later page failed, or pagination stopped pathologically. Callers
* must not treat this as an empty or complete list (e.g. "no pages" or
* auto-connect when count === 1).
* A Meta Graph edge could not be fully fetched. Callers must not read this as an
* empty or complete list.
*
* `$transient` separates a throttle or an upstream hiccup, where the real list is
* unknown, from a confirmed rejection, where Meta has answered. Unknown by default.
*/
class IncompleteMetaGraphPaginationException extends RuntimeException
{
public function __construct(?Throwable $previous = null)
public function __construct(?Throwable $previous = null, public readonly bool $transient = true)
{
parent::__construct('Meta Graph pagination did not complete.', previous: $previous);
}

View file

@ -9,11 +9,10 @@
use App\Exceptions\SocialAccount\ConnectPopupException;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\SocialAccount;
use App\Services\Social\Meta\GraphPaginator;
use App\Services\Social\Meta\ManagedPages;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Uri;
use Inertia\Inertia;
@ -21,9 +20,11 @@
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
class FacebookController extends SocialController
class FacebookController extends MetaController
{
protected string $driver = 'facebook';
protected string $pageFields = 'id,name,username,picture{url},access_token';
protected string $noPagesKey = 'accounts.popup_callback.no_facebook_pages';
protected SocialPlatform $platform = SocialPlatform::Facebook;
@ -33,6 +34,7 @@ class FacebookController extends SocialController
'pages_read_engagement',
'pages_manage_posts',
'read_insights',
'business_management',
];
public function connect(Request $request): Response
@ -63,27 +65,29 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
try {
$socialUser = Socialite::driver($this->driver)->usingGraphVersion($this->graphVersion())->user();
// Trigger public_profile and pages_show_list API calls
// These calls are needed for Meta app review permission verification
Http::get(config('trypost.platforms.facebook.graph_api').'/me', [
'fields' => 'id,name',
'access_token' => $socialUser->token,
]);
$this->touchProfile($socialUser->token);
$pages = $this->fetchPages($socialUser->token);
$granted = $this->grantedScopes($socialUser->token);
if ($granted instanceof InertiaResponse) {
return $granted;
}
$walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline());
$listed = $this->toPageCards($walk->pages);
$pages = ManagedPages::publishable($listed);
if (empty($pages)) {
return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_pages'), $this->platform->value);
return $this->noPagesOnOffer($walk, $listed);
}
$pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect);
if (empty($pages)) {
return $this->noConnectableIdentities($reconnect, 'page_not_found');
return $this->noConnectableIdentities($reconnect, 'page_not_found', $walk->complete);
}
// If only one page, connect directly
if (count($pages) === 1) {
if (count($pages) === 1 && ($walk->complete || $reconnect !== null)) {
$page = $pages[0];
$avatarPath = uploadFromUrl(data_get($page, 'picture'));
@ -98,7 +102,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'scopes' => $granted,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
@ -119,6 +123,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
'facebook_oauth' => [
'user_token' => $socialUser->token,
'user_id' => $socialUser->getId(),
'scopes' => $granted,
'pages' => $pages,
'reconnect_id' => $reconnect?->id,
],
@ -192,7 +197,7 @@ public function select(Request $request): InertiaResponse
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'scopes' => data_get($oauthData, 'scopes', $this->scopes),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
@ -219,17 +224,12 @@ public function select(Request $request): InertiaResponse
}
}
private function fetchPages(string $userToken): array
/**
* @param array<int, array<string, mixed>> $pages
* @return list<array<string, mixed>>
*/
private function toPageCards(array $pages): array
{
$pages = GraphPaginator::all(
config('trypost.platforms.facebook.graph_api').'/me/accounts',
[
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token',
'limit' => 100,
],
);
return collect($pages)->map(fn (array $page) => [
'id' => data_get($page, 'id'),
'name' => data_get($page, 'name'),
@ -241,6 +241,6 @@ private function fetchPages(string $userToken): array
private function graphVersion(): string
{
return Uri::of(config('trypost.platforms.facebook.graph_api'))->path();
return Uri::of($this->graphApi())->path();
}
}

View file

@ -10,11 +10,13 @@
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\Meta\GraphPaginator;
use Illuminate\Http\Client\ConnectionException;
use App\Services\Social\Meta\ManagedPages;
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response as ClientResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Uri;
@ -23,12 +25,22 @@
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;
class InstagramFacebookController extends SocialController
class InstagramFacebookController extends MetaController
{
protected string $driver = 'facebook';
protected string $pageFields = 'id,name,username,picture{url},access_token,instagram_business_account';
protected string $noPagesKey = 'accounts.popup_callback.no_facebook_instagram_pages';
protected SocialPlatform $platform = SocialPlatform::InstagramFacebook;
/**
* Instagram accounts described per pool round. Each Page carries its own
* access token, so the lookups cannot be batched into one `ids=` call
* they run concurrently instead, in rounds, so a portfolio holding
* hundreds of Pages does not serialise the OAuth callback.
*/
private const INSTAGRAM_LOOKUPS_PER_ROUND = 20;
protected array $scopes = [
'public_profile',
'pages_show_list',
@ -71,32 +83,49 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
->redirectUrl(route('app.social.instagram-facebook.callback'))
->user();
// Trigger public_profile API call for Meta app review verification
Http::get(config('trypost.platforms.instagram-facebook.graph_api').'/me', [
'fields' => 'id,name',
'access_token' => $socialUser->token,
]);
$this->touchProfile($socialUser->token);
$pages = $this->fetchPagesWithInstagram($socialUser->token);
$granted = $this->grantedScopes($socialUser->token);
if (empty($pages)) {
return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_instagram_pages'), $this->platform->value);
if ($granted instanceof InertiaResponse) {
return $granted;
}
$pages = $this->filterConnectableIdentities($workspace, $pages, 'ig_id', $existingAccount);
$walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline());
if (empty($pages)) {
return $this->noConnectableIdentities($existingAccount, 'page_not_found');
$listed = collect($walk->pages)
->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id')))
->values()
->all();
$publishable = ManagedPages::publishable($listed);
if (empty($publishable)) {
return $this->noPagesOnOffer($walk, $listed);
}
if (count($pages) === 1) {
return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount);
$connectable = $this->filterConnectableIdentities(
$workspace,
$publishable,
'instagram_business_account.id',
$existingAccount,
);
if (empty($connectable)) {
return $this->noConnectableIdentities($existingAccount, 'page_not_found', $walk->complete);
}
$pages = $this->describeInstagramAccounts($connectable);
if (count($pages) === 1 && ($walk->complete || $existingAccount !== null)) {
return $this->connectInstagramAccount($workspace, $pages[0], $existingAccount, $granted);
}
// Multiple pages — show selection
session([
'instagram_facebook_oauth' => [
'user_token' => $socialUser->token,
'scopes' => $granted,
'pages' => $pages,
'reconnect_id' => $existingAccount?->id,
],
@ -158,7 +187,12 @@ public function select(Request $request): InertiaResponse
return $this->popupCallback(false, __('accounts.popup_callback.page_not_found'), $this->platform->value);
}
$result = $this->connectInstagramAccount($workspace, $selectedPage, $existingAccount);
$result = $this->connectInstagramAccount(
$workspace,
$selectedPage,
$existingAccount,
data_get($oauthData, 'scopes', $this->scopes),
);
session()->forget('instagram_facebook_oauth');
@ -172,22 +206,31 @@ public function select(Request $request): InertiaResponse
}
}
private function connectInstagramAccount(Workspace $workspace, array $pageData, ?SocialAccount $existingAccount): InertiaResponse
/**
* @param array<string, mixed> $pageData
* @param array<int, string> $scopes
*/
private function connectInstagramAccount(Workspace $workspace, array $pageData, ?SocialAccount $existingAccount, array $scopes): InertiaResponse
{
$avatarPath = data_get($pageData, 'ig_picture') ? uploadFromUrl(data_get($pageData, 'ig_picture')) : null;
// A lookup we never made says nothing about the handle a reconnect already has.
$described = (bool) data_get($pageData, 'ig_described');
SocialAccount::connectIdentity(
$workspace,
$this->platform,
(string) data_get($pageData, 'ig_id'),
[
array_diff_key([
'username' => data_get($pageData, 'ig_username'),
'display_name' => data_get($pageData, 'ig_name', data_get($pageData, 'ig_username')),
'display_name' => data_get($pageData, 'ig_name')
?? data_get($pageData, 'ig_username')
?? data_get($pageData, 'page_name'),
'avatar_url' => $avatarPath,
'access_token' => data_get($pageData, 'page_access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'scopes' => $scopes,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
@ -195,58 +238,70 @@ private function connectInstagramAccount(Workspace $workspace, array $pageData,
'page_id' => data_get($pageData, 'page_id'),
'page_name' => data_get($pageData, 'page_name'),
],
],
], $described ? [] : ['username' => true, 'avatar_url' => true]),
$existingAccount,
);
return $this->connectedCallback($existingAccount);
}
private function fetchPagesWithInstagram(string $userToken): array
/**
* @param array<int, array<string, mixed>> $pages
* @return list<array<string, mixed>>
*/
private function describeInstagramAccounts(array $pages): array
{
$graphApi = (string) config('trypost.platforms.instagram-facebook.graph_api');
$pages = GraphPaginator::all("{$graphApi}/me/accounts", [
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token,instagram_business_account',
'limit' => 100,
]);
return collect($pages)
->filter(fn (array $page) => filled(data_get($page, 'instagram_business_account.id')))
->map(function (array $page) use ($graphApi) {
$igId = data_get($page, 'instagram_business_account.id');
$token = data_get($page, 'access_token');
$igData = [];
try {
$ig = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/{$igId}", [
'access_token' => $token,
'fields' => 'username,name,profile_picture_url',
]);
$igData = $ig->successful() ? $ig->json() : [];
} catch (ConnectionException) {
// Page listing still succeeds; username/avatar may be empty.
}
return [
'page_id' => data_get($page, 'id'),
'page_name' => data_get($page, 'name'),
'page_picture' => data_get($page, 'picture.data.url'),
'page_access_token' => $token,
'ig_id' => $igId,
'ig_username' => data_get($igData, 'username'),
'ig_name' => data_get($igData, 'name'),
'ig_picture' => data_get($igData, 'profile_picture_url'),
];
})
->chunk(self::INSTAGRAM_LOOKUPS_PER_ROUND)
->flatMap(fn (Collection $round) => $this->describeRound($round, $this->deadline()))
->values()
->all();
}
/**
* Past the deadline the lookups are skipped rather than dropped: the Page still
* connects, falling back to its own name, with no Instagram handle or avatar.
*
* @param Collection<int, array<string, mixed>> $pages
* @return Collection<int, array<string, mixed>>
*/
private function describeRound(Collection $pages, float $deadline): Collection
{
$pages = $pages->values();
$graphApi = $this->graphApi();
$described = microtime(true) < $deadline;
$responses = $described ? Http::pool(fn (Pool $pool) => $pages
->map(fn (array $page) => $pool
->timeout(15)
->connectTimeout(5)
->get("{$graphApi}/".data_get($page, 'instagram_business_account.id'), [
'access_token' => data_get($page, 'access_token'),
'fields' => 'username,name,profile_picture_url',
]))
->all()) : [];
return $pages->map(function (array $page, int $index) use ($responses, $described) {
$response = data_get($responses, $index);
$igData = $response instanceof ClientResponse && $response->successful() ? $response->json() : [];
return [
'page_id' => data_get($page, 'id'),
'page_name' => data_get($page, 'name'),
'page_picture' => data_get($page, 'picture.data.url'),
'page_access_token' => data_get($page, 'access_token'),
'ig_id' => data_get($page, 'instagram_business_account.id'),
'ig_username' => data_get($igData, 'username'),
'ig_name' => data_get($igData, 'name'),
'ig_picture' => data_get($igData, 'profile_picture_url'),
'ig_described' => $described && $response instanceof ClientResponse,
];
});
}
private function graphVersion(): string
{
return Uri::of(config('trypost.platforms.instagram-facebook.graph_api'))->path();
return Uri::of($this->graphApi())->path();
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Services\Social\Meta\GrantedPermissions;
use App\Services\Social\Meta\ManagedPageList;
use Illuminate\Support\Facades\Http;
use Inertia\Response as InertiaResponse;
/**
* What the Facebook and Instagram-via-Facebook connect flows share: one Meta app, one
* page walk, and one set of answers when the walk has nothing to offer.
*/
abstract class MetaController extends SocialController
{
protected string $driver = 'facebook';
private ?float $deadline = null;
/** Graph fields the page walk asks for. */
protected string $pageFields;
/** Popup key for "this login has no pages of the kind we want". */
protected string $noPagesKey;
/** When the whole callback must stop reading pages, shared by every phase of it. */
protected function deadline(): float
{
return $this->deadline ??= microtime(true) + (int) config('trypost.meta_page_walk_seconds');
}
/** Meta's app review wants to see this called; the answer is unused, so nothing it does can fail the connect. */
protected function touchProfile(string $userToken): void
{
rescue(fn () => Http::timeout(5)->connectTimeout(5)->get("{$this->graphApi()}/me", [
'fields' => 'id,name',
'access_token' => $userToken,
]), report: false);
}
/**
* The scopes this login did not refuse, or the popup refusing the connect because
* one the platform needs to publish is among them.
*
* @return array<int, string>|InertiaResponse
*/
protected function grantedScopes(string $userToken): array|InertiaResponse
{
$granted = GrantedPermissions::for($this->graphApi(), $userToken, $this->scopes);
return array_diff($this->platform->requiredPublishScopes(), $granted) === []
? $granted
: $this->popupCallback(false, __('accounts.popup_callback.publish_permission_refused'), $this->platform->value);
}
/**
* A walk that could not finish outranks the other answers, since neither would be
* true of what it did not read.
*
* @param array<int, array<string, mixed>> $listed
*/
protected function noPagesOnOffer(ManagedPageList $walk, array $listed): InertiaResponse
{
return $this->popupCallback(false, __(match (true) {
! $walk->complete => 'accounts.popup_callback.pages_read_incomplete',
empty($listed) => $this->noPagesKey,
default => 'accounts.popup_callback.pages_missing_permission',
}), $this->platform->value);
}
}

View file

@ -26,6 +26,12 @@ class SocialController extends Controller
{
protected SocialPlatform $platform;
/** The platform's API host, keyed in config by the enum value. */
protected function graphApi(): string
{
return (string) config("trypost.platforms.{$this->platform->value}.graph_api");
}
protected function ensurePlatformEnabled(): void
{
if (! $this->platform->isEnabled()) {
@ -149,13 +155,16 @@ protected function reconnectAccount(Workspace $workspace, mixed $reconnectId = n
* Nothing on this network is left to connect: the card being reconnected is
* gone from the provider, this login has nothing left to offer, or the
* single slot is taken.
*
* A taken slot is a fact about our own rows, so it stands even when the provider
* listing came back short. The other two answers depend on having seen everything.
*/
protected function noConnectableIdentities(?SocialAccount $reconnect, string $missingKey): Response
protected function noConnectableIdentities(?SocialAccount $reconnect, string $missingKey, bool $listingComplete = true): Response
{
$key = match (true) {
$reconnect !== null => $missingKey,
(bool) config('trypost.allow_multiple_social_accounts') => 'all_connected',
default => 'network_taken',
! (bool) config('trypost.allow_multiple_social_accounts') && $reconnect === null => 'network_taken',
$listingComplete => $reconnect !== null ? $missingKey : 'all_connected',
default => 'pages_read_incomplete',
};
return $this->popupCallback(false, __("accounts.popup_callback.{$key}"), $this->platform->value);

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
/**
* The scopes a Meta login is not known to have refused.
*
* Only a scope Meta explicitly reports declined or expired is dropped. An absence is
* unknown, not a refusal `/me/permissions` is paginated and Meta does not document
* that it echoes scope strings verbatim, and failForMissingScopes() blocks publishing
* on a scope missing from this column.
*
* A failed request is unknown in the same way, so it yields the requested list whole.
* That is where this app already stood before it asked Meta at all: a scope declined
* during a Graph outage still surfaces, later, when publishing rejects it.
*/
class GrantedPermissions
{
private const REFUSED = ['declined', 'expired'];
/**
* @param array<int, string> $requested
* @return array<int, string>
*/
public static function for(string $graphApi, string $userToken, array $requested): array
{
try {
$response = Http::timeout(15)->connectTimeout(5)->get("{$graphApi}/me/permissions", [
'access_token' => $userToken,
]);
} catch (ConnectionException) {
return $requested;
}
if ($response->failed()) {
return $requested;
}
$reported = $response->collect('data')->keyBy(fn ($permission) => data_get($permission, 'permission'));
return collect($requested)
->reject(fn (string $scope) => in_array(
data_get($reported, "{$scope}.status"),
self::REFUSED,
true,
))
->values()
->all();
}
}

View file

@ -25,7 +25,10 @@
* limit". https://developers.facebook.com/docs/graph-api/guides/error-handling/
* - Business Use Case (BUC) Rate Limits (Page/system-user tokens Facebook
* and InstagramFacebook accounts here use Page tokens): code 80001 "Pages
* API", code 80002 "Instagram Platform". Unlike Platform Rate Limits, BUC
* API", code 80002 "Instagram Platform", and code 32 "Pages API with a User
* token" — which the connect flow hits, since the portfolio walk reads
* /me/accounts, /me/businesses and the owned_pages / client_pages edges with
* the user token straight from OAuth. Unlike Platform Rate Limits, BUC
* rejections come back as an ordinary HTTP 400, not 429. BUC also covers
* several other Meta products (Marketing API, WhatsApp, Messenger, ...)
* with their own 80000-series codes irrelevant here since this app never
@ -48,7 +51,7 @@ class GraphError
* Codes Meta uses for rate-limit and other transient upstream problems.
* These must never disconnect a still-valid token.
*/
private const TRANSIENT_CODES = [1, 2, 4, 17, 80001, 80002];
private const TRANSIENT_CODES = [1, 2, 4, 17, 32, 80001, 80002];
/**
* Whether the given Meta Graph error body is a known rate-limit or
@ -73,7 +76,7 @@ public static function isTransientFailure(Response $response): bool
{
return $response->serverError()
|| $response->status() === 429
|| self::isTransient($response->json());
|| self::isTransient(is_array($body = $response->json()) ? $body : null);
}
/**

View file

@ -14,11 +14,10 @@
use Throwable;
/**
* Collects every item from a paginated Meta Graph API edge by following `paging.next`.
* Collects every item from a paginated Meta Graph edge by following `paging.next`.
*
* Stops only when pagination is exhausted. Request failures and pathological cases
* (repeated next URL, off-host next URL, extreme page count) throw so callers never
* confuse an error with an empty Page list or auto-connect on a truncated list.
* Failures and pathological cases (repeated next URL, off-host next URL, extreme page
* count) throw, so no caller confuses an error with an empty list.
*/
class GraphPaginator
{
@ -30,11 +29,12 @@ class GraphPaginator
/**
* @param array<string, mixed> $query
* @param float|null $deadline microtime after which no *further* page is fetched; the first always is
* @return list<array<string, mixed>>
*
* @throws IncompleteMetaGraphPaginationException
*/
public static function all(string $url, array $query = []): array
public static function all(string $url, array $query = [], ?float $deadline = null): array
{
$items = collect();
$fetched = 0;
@ -51,6 +51,10 @@ public static function all(string $url, array $query = []): array
self::abort($next, $fetched, reason: 'Meta Graph pagination stopped: repeated paging URL');
}
if ($fetched > 0 && $deadline !== null && microtime(true) >= $deadline) {
self::abort($next, $fetched, reason: 'Meta Graph pagination stopped: out of time');
}
$seen[$next] = true;
try {
@ -83,6 +87,12 @@ public static function all(string $url, array $query = []): array
return $items->values()->all();
}
/** Classify and log a failed response a caller read itself, rather than walked here. */
public static function failure(string $url, Response $response): IncompleteMetaGraphPaginationException
{
return self::describe($url, 0, response: $response);
}
/**
* @throws IncompleteMetaGraphPaginationException
*/
@ -93,14 +103,30 @@ private static function abort(
?Response $response = null,
?string $reason = null,
): never {
Log::error($reason ?? ($e ? 'Meta Graph pagination connection failed' : 'Meta Graph pagination request failed'), array_filter([
throw self::describe($url, $fetched, $e, $response, $reason);
}
/** A confirmed rejection is Meta answering, so it warns; an unknown stays an error. */
private static function describe(
string $url,
int $fetched,
?Throwable $e = null,
?Response $response = null,
?string $reason = null,
): IncompleteMetaGraphPaginationException {
$transient = $response === null || GraphError::isTransientFailure($response);
$message = $reason ?? ($e ? 'Meta Graph pagination connection failed' : 'Meta Graph pagination request failed');
$context = array_filter([
'url' => TokenRedactor::redact($url),
'error' => $e?->getMessage(),
'status' => $response?->status(),
'body' => $response ? TokenRedactor::redact($response->body()) : null,
'fetched' => $fetched > 0 ? $fetched : null,
]));
]);
throw new IncompleteMetaGraphPaginationException($e);
$transient ? Log::error($message, $context) : Log::warning($message, $context);
return new IncompleteMetaGraphPaginationException($e, transient: $transient);
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
/** What a page walk found, and whether it found everything. */
final readonly class ManagedPageList
{
/**
* @param list<array<string, mixed>> $pages
*/
public function __construct(public array $pages, public bool $complete) {}
}

View file

@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Meta;
use App\Exceptions\Social\IncompleteMetaGraphPaginationException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Pool;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Uri;
/**
* Every Facebook Page a login can publish to.
*
* `/me/accounts` only returns Pages the person holds a Page role on, so a Business
* Portfolio admin the New Pages Experience norm gets nothing there. The
* portfolio's `owned_pages` and `client_pages` edges are read too and merged by id.
*
* `/me/accounts` is the list everything else is added to, so it is all or nothing:
* failing it running out of budget included aborts rather than handing back a base
* this walk cannot vouch for. Everything after it keeps what arrived. A refusal there
* is an answer a Page this login cannot enumerate is one it cannot get a token for,
* so it was never connectable. An unknown is not: a throttle, a hiccup, a budget or a
* ceiling leaves the walk unable to vouch for itself, and it says so, so no caller
* auto-connects off a list that may be missing something.
*/
class ManagedPages
{
private const PER_PAGE = 100;
private const EDGES_PER_ROUND = 20;
private const PORTFOLIO_SCOPE = 'business_management';
/** Portfolios read, and the page size asked of `/me/businesses`, which is read once. */
public const MAX_PORTFOLIOS = 100;
/** Cursor requests allowed across the whole walk; these cannot be pooled. */
public const MAX_CONTINUATIONS = 25;
private bool $complete = true;
private int $continuations = 0;
private readonly float $deadline;
private function __construct(
private readonly string $graphApi,
private readonly string $userToken,
private readonly string $fields,
?float $deadline,
) {
$this->deadline = $deadline ?? microtime(true) + (int) config('trypost.meta_page_walk_seconds');
}
/**
* @param array<int, string> $grantedScopes
*
* @throws IncompleteMetaGraphPaginationException when `/me/accounts` itself fails
*/
public static function forUser(
string $graphApi,
string $userToken,
string $fields,
array $grantedScopes = [self::PORTFOLIO_SCOPE],
?float $deadline = null,
): ManagedPageList {
return (new self($graphApi, $userToken, $fields, $deadline))->walk($grantedScopes);
}
/**
* Meta returns `access_token` on a Page only when the login holds a role on that
* Page being in the portfolio that owns it is not enough so a portfolio can
* list Pages this login cannot post to. Connecting one produces an account that
* cannot publish, so callers separate it from a Page they never had.
*
* @param array<int, array<string, mixed>> $pages
* @return list<array<string, mixed>>
*/
public static function publishable(array $pages): array
{
return collect($pages)
->filter(fn (array $page) => filled(data_get($page, 'access_token')))
->values()
->all();
}
/**
* @param array<int, string> $grantedScopes
*/
private function walk(array $grantedScopes): ManagedPageList
{
$pages = collect(GraphPaginator::all("{$this->graphApi}/me/accounts", $this->query(), $this->deadline));
if (in_array(self::PORTFOLIO_SCOPE, $grantedScopes, true)) {
$pages = $pages->concat($this->portfolioPages());
}
return new ManagedPageList(
$pages
->sortBy(fn (array $page) => filled(data_get($page, 'access_token')) ? 0 : 1)
->unique(fn (array $page) => (string) data_get($page, 'id'))
->values()
->all(),
$this->complete,
);
}
/**
* @return Collection<int, array<string, mixed>>
*/
private function portfolioPages(): Collection
{
return collect($this->businessIds())
->crossJoin(['owned_pages', 'client_pages'])
->map(fn (array $edge) => Uri::of("{$this->graphApi}/{$edge[0]}/{$edge[1]}")->withQuery($this->query())->value())
->chunk(self::EDGES_PER_ROUND)
->flatMap($this->readRound(...));
}
/** No single request may outlive the budget by its own timeout. */
private function timeout(): int
{
return max(1, min(15, (int) ceil($this->deadline - microtime(true))));
}
/** Every per-request budget is bounded, but the walk sits in an OAuth callback. */
private function outOfTime(): bool
{
if (microtime(true) < $this->deadline) {
return false;
}
$this->complete = false;
return true;
}
/**
* @param Collection<int, string> $urls
* @return Collection<int, array<string, mixed>>
*/
private function readRound(Collection $urls): Collection
{
if ($this->outOfTime()) {
return collect();
}
$urls = $urls->values();
$responses = Http::pool(fn (Pool $pool) => $urls
->map(fn (string $url) => $pool->timeout($this->timeout())->connectTimeout(5)->get($url))
->all());
return $urls->flatMap(function (string $url, int $index) use ($responses) {
$response = data_get($responses, $index);
if (! $response instanceof Response) {
$this->complete = false;
return [];
}
if ($response->failed()) {
$this->note($url, $response);
return [];
}
return $response->collect('data')->concat($this->rest($url, $response->json('paging.next')));
});
}
/**
* Follows what is left of an edge, one budgeted request at a time. A cursor cannot
* be pooled, so this is the only serial path in the walk. Whatever arrived before a
* cut-off is kept; only the walk's completeness is lost.
*
* @return list<array<string, mixed>>
*/
private function rest(string $url, mixed $next): array
{
$pages = [];
while (is_string($next) && filled($next)) {
if ($this->continuations >= self::MAX_CONTINUATIONS || $this->outOfTime() || Uri::of($next)->host() !== Uri::of($url)->host()) {
$this->complete = false;
break;
}
$this->continuations++;
try {
$response = Http::timeout($this->timeout())->connectTimeout(5)->get($next);
} catch (ConnectionException) {
$this->complete = false;
break;
}
if ($response->failed()) {
GraphPaginator::failure($next, $response);
$this->complete = false;
break;
}
$pages = [...$pages, ...$response->collect('data')->all()];
$next = $response->json('paging.next');
}
return $pages;
}
/**
* Reading one page is what bounds the walk: paginating here would let one login
* spawn thousands of edge reads. More portfolios than fit is incomplete, not failed.
*
* A refusal here is not an answer about any Page. Refusing one edge says those Pages
* are unreadable, and unreadable is unconnectable; refusing the index says no edge
* was ever read, and Meta's own reference has Pages carrying a token on those edges
* while `/me/accounts` omits them, which is the whole reason this walk exists.
*
* @return list<string>
*/
private function businessIds(): array
{
$url = "{$this->graphApi}/me/businesses";
try {
$response = Http::timeout($this->timeout())->connectTimeout(5)->get($url, [
'access_token' => $this->userToken,
'limit' => self::MAX_PORTFOLIOS,
]);
} catch (ConnectionException) {
$this->complete = false;
return [];
}
if ($response->failed()) {
GraphPaginator::failure($url, $response);
$this->complete = false;
return [];
}
if (filled($response->json('paging.next'))) {
$this->complete = false;
}
return $response->collect('data')
->pluck('id')
->filter()
->map(strval(...))
->take(self::MAX_PORTFOLIOS)
->values()
->all();
}
/**
* A rejection is Meta answering that this login reaches nothing there. Anything
* else leaves the edge unread, which the walk cannot vouch for.
*/
private function note(string $url, Response $response): void
{
if (GraphPaginator::failure($url, $response)->transient) {
$this->complete = false;
}
}
/**
* @return array<string, mixed>
*/
private function query(): array
{
return ['access_token' => $this->userToken, 'fields' => $this->fields, 'limit' => self::PER_PAGE];
}
}

View file

@ -105,11 +105,6 @@
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"dev:ssr": [
"npm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
"lint": [
"pint --parallel"
],

View file

@ -23,7 +23,7 @@
'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
'enabled' => (bool) env('INERTIA_SSR_ENABLED', false),
'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),

View file

@ -16,6 +16,19 @@
'self_hosted' => env('SELF_HOSTED', true),
/*
|--------------------------------------------------------------------------
| Meta page walk budget
|--------------------------------------------------------------------------
|
| Seconds the Facebook/Instagram page walk may spend before it returns what
| it has and reports itself incomplete. It runs inside the OAuth callback,
| so this must stay well under the web server's request timeout.
|
*/
'meta_page_walk_seconds' => (int) env('META_PAGE_WALK_SECONDS', 20),
/*
|--------------------------------------------------------------------------
| Multiple social accounts per network

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'فشل جلب الملف الشخصي.',
'page_not_found' => 'لم يتم العثور على الصفحة.',
'channel_not_found' => 'لم يتم العثور على القناة.',
'pages_read_incomplete' => 'لم نتمكن من إكمال قراءة صفحاتك. حاول مرة أخرى بعد قليل.',
'publish_permission_refused' => 'رفض هذا الحساب إذنًا نحتاجه للنشر. أعد الاتصال واقبل جميع الأذونات.',
'pages_missing_permission' => 'وجدنا صفحات، لكن لا يمكنك النشر في أي منها. تحتاج إلى دور على الصفحة نفسها وقبول جميع الأذونات.',
'no_facebook_pages' => 'لم يتم العثور على صفحات Facebook. يجب أن تكون مشرفًا على صفحة واحدة على الأقل.',
'no_facebook_instagram_pages' => 'لم يتم العثور على صفحات Facebook مرتبطة بحسابات Instagram.',
'no_youtube_channels' => 'لم يتم العثور على قنوات YouTube. يرجى إنشاء قناة أولًا.',

View file

@ -157,6 +157,9 @@
'failed_to_get_profile' => 'Profil konnte nicht abgerufen werden.',
'page_not_found' => 'Seite nicht gefunden.',
'channel_not_found' => 'Kanal nicht gefunden.',
'pages_read_incomplete' => 'Wir konnten deine Seiten nicht vollständig lesen. Bitte versuche es gleich noch einmal.',
'publish_permission_refused' => 'Diese Anmeldung hat eine zum Posten nötige Berechtigung abgelehnt. Verbinde erneut und akzeptiere alle.',
'pages_missing_permission' => 'Wir haben Seiten gefunden, aber keine zum Posten. Du brauchst eine Rolle auf der Seite selbst und alle Berechtigungen.',
'no_facebook_pages' => 'Keine Facebook-Seiten gefunden. Du musst Administrator mindestens einer Seite sein.',
'no_facebook_instagram_pages' => 'Keine Facebook-Seiten mit verknüpften Instagram-Konten gefunden.',
'no_youtube_channels' => 'Keine YouTube-Kanäle gefunden. Bitte erstelle zuerst einen Kanal.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Η ανάκτηση του προφίλ απέτυχε.',
'page_not_found' => 'Η σελίδα δεν βρέθηκε.',
'channel_not_found' => 'Το κανάλι δεν βρέθηκε.',
'pages_read_incomplete' => 'Δεν μπορέσαμε να διαβάσουμε όλες τις Σελίδες σας. Δοκιμάστε ξανά σε λίγο.',
'publish_permission_refused' => 'Αυτή η σύνδεση αρνήθηκε μια άδεια που χρειαζόμαστε για δημοσίευση. Συνδεθείτε ξανά και αποδεχτείτε όλες.',
'pages_missing_permission' => 'Βρήκαμε Σελίδες, αλλά σε καμία δεν μπορείτε να δημοσιεύσετε. Χρειάζεστε ρόλο στην ίδια τη Σελίδα και όλες τις άδειες.',
'no_facebook_pages' => 'Δεν βρέθηκαν σελίδες Facebook. Πρέπει να είστε διαχειριστής τουλάχιστον μίας σελίδας.',
'no_facebook_instagram_pages' => 'Δεν βρέθηκαν σελίδες Facebook με συνδεδεμένους λογαριασμούς Instagram.',
'no_youtube_channels' => 'Δεν βρέθηκαν κανάλια YouTube. Παρακαλούμε δημιουργήστε πρώτα ένα κανάλι.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Failed to get profile.',
'page_not_found' => 'Page not found.',
'channel_not_found' => 'Channel not found.',
'pages_read_incomplete' => 'We could not finish reading your Pages. Please try again in a moment.',
'publish_permission_refused' => 'This login refused a permission we need to post. Reconnect and accept all of them.',
'pages_missing_permission' => 'We found Pages, but none you can post to. You need a role on the Page itself, and every permission accepted.',
'no_facebook_pages' => 'No Facebook Pages found. You need to be an admin of at least one page.',
'no_facebook_instagram_pages' => 'No Facebook Pages with linked Instagram accounts found.',
'no_youtube_channels' => 'No YouTube channels found. Please create a channel first.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Falló al obtener el perfil.',
'page_not_found' => 'Página no encontrada.',
'channel_not_found' => 'Canal no encontrado.',
'pages_read_incomplete' => 'No pudimos terminar de leer tus páginas. Inténtalo de nuevo en un momento.',
'publish_permission_refused' => 'Este inicio de sesión rechazó un permiso necesario para publicar. Vuelve a conectar y acéptalos todos.',
'pages_missing_permission' => 'Encontramos páginas, pero ninguna en la que puedas publicar. Necesitas un rol en la página y aceptar todos los permisos.',
'no_facebook_pages' => 'No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.',
'no_facebook_instagram_pages' => 'No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.',
'no_youtube_channels' => 'No se encontraron canales de YouTube. Crea un canal primero.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Impossible de récupérer le profil.',
'page_not_found' => 'Page introuvable.',
'channel_not_found' => 'Chaîne introuvable.',
'pages_read_incomplete' => 'Nous navons pas pu finir de lire vos Pages. Réessayez dans un instant.',
'publish_permission_refused' => 'Cette connexion a refusé une autorisation nécessaire pour publier. Reconnectez-vous en les acceptant toutes.',
'pages_missing_permission' => 'Nous avons trouvé des Pages, mais aucune où publier. Il vous faut un rôle sur la Page elle-même et toutes les autorisations acceptées.',
'no_facebook_pages' => 'Aucune page Facebook trouvée. Vous devez être administrateur d\'au moins une page.',
'no_facebook_instagram_pages' => 'Aucune page Facebook associée à un compte Instagram trouvée.',
'no_youtube_channels' => 'Aucune chaîne YouTube trouvée. Veuillez d\'abord créer une chaîne.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Impossibile ottenere il profilo.',
'page_not_found' => 'Pagina non trovata.',
'channel_not_found' => 'Canale non trovato.',
'pages_read_incomplete' => 'Non siamo riusciti a leggere tutte le tue Pagine. Riprova tra poco.',
'publish_permission_refused' => 'Questo accesso ha rifiutato una autorizzazione necessaria per pubblicare. Riconnetti accettandole tutte.',
'pages_missing_permission' => 'Abbiamo trovato Pagine, ma nessuna su cui pubblicare. Serve un ruolo sulla Pagina stessa e tutte le autorizzazioni accettate.',
'no_facebook_pages' => 'Nessuna pagina Facebook trovata. Devi essere amministratore di almeno una pagina.',
'no_facebook_instagram_pages' => 'Nessuna pagina Facebook con account Instagram collegati trovata.',
'no_youtube_channels' => 'Nessun canale YouTube trovato. Crea prima un canale.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'プロフィールの取得に失敗しました。',
'page_not_found' => 'ページが見つかりません。',
'channel_not_found' => 'チャンネルが見つかりません。',
'pages_read_incomplete' => 'ページをすべて読み取れませんでした。少し時間をおいて再度お試しください。',
'publish_permission_refused' => '投稿に必要な権限が許可されませんでした。再接続してすべて許可してください。',
'pages_missing_permission' => 'ページは見つかりましたが、投稿できるものがありません。ページ自体での役割と、すべての権限が必要です。',
'no_facebook_pages' => 'Facebook ページが見つかりません。少なくとも 1 つのページの管理者である必要があります。',
'no_facebook_instagram_pages' => 'Instagram アカウントが連携された Facebook ページが見つかりません。',
'no_youtube_channels' => 'YouTube チャンネルが見つかりません。先にチャンネルを作成してください。',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => '프로필을 가져오지 못했습니다.',
'page_not_found' => '페이지를 찾을 수 없습니다.',
'channel_not_found' => '채널을 찾을 수 없습니다.',
'pages_read_incomplete' => '페이지를 모두 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.',
'publish_permission_refused' => '게시에 필요한 권한이 거부되었습니다. 다시 연결하고 모두 허용해 주세요.',
'pages_missing_permission' => '페이지는 찾았지만 게시할 수 있는 곳이 없습니다. 페이지 자체의 역할과 모든 권한이 필요합니다.',
'no_facebook_pages' => 'Facebook 페이지를 찾을 수 없습니다. 최소 한 개 페이지의 관리자여야 합니다.',
'no_facebook_instagram_pages' => 'Instagram 계정이 연결된 Facebook 페이지를 찾을 수 없습니다.',
'no_youtube_channels' => 'YouTube 채널을 찾을 수 없습니다. 먼저 채널을 만드세요.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Kon profiel niet ophalen.',
'page_not_found' => 'Pagina niet gevonden.',
'channel_not_found' => 'Kanaal niet gevonden.',
'pages_read_incomplete' => 'We konden je paginas niet volledig uitlezen. Probeer het zo meteen opnieuw.',
'publish_permission_refused' => 'Deze login heeft een recht geweigerd dat we nodig hebben om te posten. Maak opnieuw verbinding en accepteer alles.',
'pages_missing_permission' => 'We vonden pagina\'s, maar geen waar je op kunt posten. Je hebt een rol op de pagina zelf nodig en alle rechten.',
'no_facebook_pages' => 'Geen Facebook-pagina\'s gevonden. Je moet beheerder zijn van ten minste één pagina.',
'no_facebook_instagram_pages' => 'Geen Facebook-pagina\'s met gekoppelde Instagram-accounts gevonden.',
'no_youtube_channels' => 'Geen YouTube-kanalen gevonden. Maak eerst een kanaal aan.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Nie udało się pobrać profilu.',
'page_not_found' => 'Nie znaleziono strony.',
'channel_not_found' => 'Nie znaleziono kanału.',
'pages_read_incomplete' => 'Nie udało się odczytać wszystkich Twoich stron. Spróbuj ponownie za chwilę.',
'publish_permission_refused' => 'To logowanie odrzuciło uprawnienie potrzebne do publikowania. Połącz ponownie i zaakceptuj wszystkie.',
'pages_missing_permission' => 'Znaleźliśmy strony, ale na żadnej nie możesz publikować. Potrzebujesz roli na samej stronie i wszystkich uprawnień.',
'no_facebook_pages' => 'Nie znaleziono stron na Facebooku. Musisz być administratorem co najmniej jednej strony.',
'no_facebook_instagram_pages' => 'Nie znaleziono stron na Facebooku z powiązanymi kontami Instagram.',
'no_youtube_channels' => 'Nie znaleziono kanałów YouTube. Najpierw utwórz kanał.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Falha ao obter perfil.',
'page_not_found' => 'Página não encontrada.',
'channel_not_found' => 'Canal não encontrado.',
'pages_read_incomplete' => 'Não conseguimos terminar de ler suas páginas. Tente novamente em instantes.',
'publish_permission_refused' => 'Este login recusou uma permissão necessária para publicar. Reconecte aceitando todas.',
'pages_missing_permission' => 'Encontramos páginas, mas nenhuma em que você possa publicar. É preciso ter um cargo na própria página e aceitar todas as permissões.',
'no_facebook_pages' => 'Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.',
'no_facebook_instagram_pages' => 'Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.',
'no_youtube_channels' => 'Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Не удалось получить профиль.',
'page_not_found' => 'Страница не найдена.',
'channel_not_found' => 'Канал не найден.',
'pages_read_incomplete' => 'Не удалось прочитать все ваши страницы. Попробуйте ещё раз через минуту.',
'publish_permission_refused' => 'При входе отклонено разрешение, нужное для публикации. Подключитесь заново и примите все.',
'pages_missing_permission' => 'Мы нашли страницы, но публиковать не на чем. Нужна роль на самой странице и все разрешения.',
'no_facebook_pages' => 'Страницы Facebook не найдены. Вы должны быть администратором хотя бы одной страницы.',
'no_facebook_instagram_pages' => 'Не найдено страниц Facebook со связанными аккаунтами Instagram.',
'no_youtube_channels' => 'Каналы YouTube не найдены. Сначала создайте канал.',

View file

@ -157,6 +157,9 @@
'failed_to_get_profile' => 'Profil alınamadı.',
'page_not_found' => 'Sayfa bulunamadı.',
'channel_not_found' => 'Kanal bulunamadı.',
'pages_read_incomplete' => 'Sayfalarınızın tamamını okuyamadık. Birazdan tekrar deneyin.',
'publish_permission_refused' => 'Bu girişte paylaşım için gereken bir izin reddedildi. Yeniden bağlanıp hepsini kabul edin.',
'pages_missing_permission' => 'Sayfalar bulduk ama paylaşım yapabileceğiniz yok. Sayfanın kendisinde bir rolünüz ve tüm izinler gerekli.',
'no_facebook_pages' => 'Facebook Sayfası bulunamadı. En az bir sayfanın yöneticisi olmanız gerekir.',
'no_facebook_instagram_pages' => 'Bağlı Instagram hesabı olan Facebook Sayfası bulunamadı.',
'no_youtube_channels' => 'YouTube kanalı bulunamadı. Lütfen önce bir kanal oluşturun.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => 'Не вдалося отримати профіль.',
'page_not_found' => 'Сторінку не знайдено.',
'channel_not_found' => 'Канал не знайдено.',
'pages_read_incomplete' => 'Не вдалося прочитати всі ваші сторінки. Спробуйте ще раз за хвилину.',
'publish_permission_refused' => 'Під час входу відхилено дозвіл, потрібний для публікації. Підключіться знову та надайте всі.',
'pages_missing_permission' => 'Ми знайшли сторінки, але публікувати нема де. Потрібна роль на самій сторінці та всі дозволи.',
'no_facebook_pages' => 'Сторінок Facebook не знайдено. Ви маєте бути адміністратором хоча б однієї сторінки.',
'no_facebook_instagram_pages' => 'Не знайдено сторінок Facebook із підключеними акаунтами Instagram.',
'no_youtube_channels' => 'Каналів YouTube не знайдено. Спочатку створіть канал.',

View file

@ -155,6 +155,9 @@
'failed_to_get_profile' => '获取主页信息失败。',
'page_not_found' => '未找到页面。',
'channel_not_found' => '未找到频道。',
'pages_read_incomplete' => '我们没能读取你的全部主页。请稍后再试。',
'publish_permission_refused' => '本次登录拒绝了发布所需的权限。请重新连接并接受全部权限。',
'pages_missing_permission' => '我们找到了主页,但没有你能发布的。你需要在主页本身拥有角色,并接受全部权限。',
'no_facebook_pages' => '未找到 Facebook 主页。你至少需要是一个主页的管理员。',
'no_facebook_instagram_pages' => '未找到关联了 Instagram 账号的 Facebook 主页。',
'no_youtube_channels' => '未找到 YouTube 频道,请先创建一个频道。',

View file

@ -15,6 +15,8 @@
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(function () {
Http::preventStrayRequests();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
@ -55,8 +57,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_123',
@ -104,8 +111,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_123',
@ -142,8 +154,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_1',
@ -182,8 +199,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [],
], 200),
]);
@ -211,6 +233,8 @@
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400),
]);
@ -243,6 +267,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -299,6 +325,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -350,6 +378,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -408,8 +438,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_new',
@ -702,8 +737,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_1',
@ -761,8 +801,13 @@
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_other',
@ -865,13 +910,18 @@
->with('facebook')
->andReturn($driverMock);
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
['id' => 'page-1', 'name' => 'Only Page', 'access_token' => 'page-token'],
],
], 200),
'https://graph.facebook.com/*' => Http::response(['id' => 'fb-user', 'name' => 'Me'], 200),
"{$graphApi}/*" => Http::response(['id' => 'fb-user', 'name' => 'Me'], 200),
]);
$this->actingAs($this->user)
@ -882,3 +932,630 @@
->where('message', __('accounts.popup_callback.all_connected'))
);
});
test('facebook callback connects a page the user only administers through a business portfolio', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [
[
'id' => 'page_owned_by_client',
'name' => "Client's Page",
'username' => 'clientpage',
'picture' => ['data' => ['url' => null]],
'access_token' => 'portfolio-page-token',
],
],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::Facebook->value,
'platform_user_id' => 'page_owned_by_client',
'display_name' => "Client's Page",
'status' => Status::Connected->value,
]);
});
test('facebook callback still reports no pages when the portfolio has none either', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.no_facebook_pages')));
});
test('facebook callback offers every portfolio page when the portfolio holds more than one', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [
[
'id' => 'page_owned',
'name' => 'Owned Page',
'username' => 'owned',
'picture' => ['data' => ['url' => null]],
'access_token' => 'owned-token',
],
],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response([
'data' => [
[
'id' => 'page_client',
'name' => 'Client Page',
'username' => 'client',
'picture' => ['data' => ['url' => null]],
'access_token' => 'client-token',
],
],
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertRedirect(route('app.social.facebook.select-page'));
expect(session('facebook_oauth.pages'))->toHaveCount(2)
->and(data_get(session('facebook_oauth.pages'), '0.id'))->toBe('page_owned')
->and(data_get(session('facebook_oauth.pages'), '1.id'))->toBe('page_client');
$this->actingAs($this->user)
->get(route('app.social.facebook.select-page'))
->assertInertia(fn (AssertableInertia $page) => $page
->component('accounts/FacebookPageSelect')
->has('pages', 2));
$this->actingAs($this->user)
->post(route('app.social.facebook.select'), ['page_id' => 'page_client'])
->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$account = SocialAccount::where('platform_user_id', 'page_client')->sole();
expect($account->workspace_id)->toBe($this->workspace->id)
->and($account->platform)->toBe(Platform::Facebook)
->and($account->display_name)->toBe('Client Page')
->and($account->access_token)->toBe('client-token');
});
test('facebook callback merges a portfolio page with the one me/accounts already returned', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page_role',
'name' => 'Role Page',
'username' => 'role',
'picture' => ['data' => ['url' => null]],
'access_token' => 'role-token',
],
],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [
[
'id' => 'page_portfolio',
'name' => 'Portfolio Page',
'username' => 'portfolio',
'picture' => ['data' => ['url' => null]],
'access_token' => 'portfolio-token',
],
],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertRedirect(route('app.social.facebook.select-page'));
expect(collect(session('facebook_oauth.pages'))->pluck('id')->all())
->toBe(['page_role', 'page_portfolio']);
});
test('facebook callback says the permission is missing when meta lists a page without a token', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'pages_show_list', 'status' => 'granted'],
['permission' => 'pages_read_engagement', 'status' => 'declined'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_123', 'name' => 'My Page', 'picture' => ['data' => ['url' => null]]]],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.facebook.callback'));
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.pages_missing_permission')));
$this->assertDatabaseCount('social_accounts', 0);
});
test('facebook drops a scope meta reports as declined', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'pages_show_list', 'status' => 'granted'],
['permission' => 'pages_manage_posts', 'status' => 'granted'],
['permission' => 'business_management', 'status' => 'declined'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [[
'id' => 'page_123',
'name' => 'My Page',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)->get(route('app.social.facebook.callback'));
expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes)
->toContain('pages_manage_posts')
->not->toContain('business_management');
});
test('facebook keeps a scope meta never mentions rather than guessing it was refused', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'public_profile', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [[
'id' => 'page_123',
'name' => 'My Page',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)->get(route('app.social.facebook.callback'));
expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes)
->toContain('pages_manage_posts');
});
test('facebook falls back to the requested scopes when meta will not list permissions', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['error' => ['message' => 'nope']], 500),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [[
'id' => 'page_123',
'name' => 'My Page',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)->get(route('app.social.facebook.callback'));
expect(SocialAccount::where('platform_user_id', 'page_123')->sole()->scopes)
->toContain('business_management');
});
test('facebook reconnects a card whose page is now only reachable through a portfolio', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Facebook,
'platform_user_id' => 'page_portfolio',
'access_token' => 'stale-token',
'status' => Status::Disconnected,
]);
session([
'social_connect_workspace' => $this->workspace->id,
'social_reconnect_id' => $account->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'business_management', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response(['data' => [
[
'id' => 'page_portfolio',
'name' => 'Reconnected Page',
'picture' => ['data' => ['url' => null]],
'access_token' => 'fresh-token',
],
[
'id' => 'page_other',
'name' => 'Someone Else',
'picture' => ['data' => ['url' => null]],
'access_token' => 'other-token',
],
]], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook->value)->count())->toBe(1);
$account->refresh();
expect($account->access_token)->toBe('fresh-token')
->and($account->display_name)->toBe('Reconnected Page')
->and($account->status)->toBe(Status::Connected);
});
test('facebook refuses a login that declined the permission needed to publish', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'pages_manage_posts', 'status' => 'declined'],
]], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.publish_permission_refused')));
$this->assertDatabaseCount('social_accounts', 0);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/accounts'));
});
test('facebook asks rather than auto-connecting a lone page found by an incomplete walk', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'business_management', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_1',
'name' => 'The Only One We Saw',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'error' => ['message' => 'Application request limit reached', 'code' => 4],
], 400),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertRedirect(route('app.social.facebook.select-page'));
expect(session('facebook_oauth.pages'))->toHaveCount(1);
$this->assertDatabaseCount('social_accounts', 0);
});
test('facebook still connects a lone page when the walk saw everything', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'business_management', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_1',
'name' => 'The Only One',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseCount('social_accounts', 1);
});
test('facebook says the walk was cut short rather than claiming there are no pages', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'business_management', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'error' => ['message' => 'Application request limit reached', 'code' => 4],
], 400),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.pages_read_incomplete')));
});
test('facebook says the walk was cut short rather than claiming everything is connected', function () {
config()->set('trypost.allow_multiple_social_accounts', true);
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Facebook,
'platform_user_id' => 'page_taken',
]);
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'business_management', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_taken',
'name' => 'Already Connected',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'busy', 'code' => 2]], 500),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.pages_read_incomplete')));
});
test('facebook still says the slot is taken when the walk came back short', function () {
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Facebook,
'platform_user_id' => 'page_taken',
]);
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock());
$graphApi = config('trypost.platforms.facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [
['permission' => 'business_management', 'status' => 'granted'],
]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_taken',
'name' => 'Already Connected',
'picture' => ['data' => ['url' => null]],
'access_token' => 'page-token',
]]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'busy', 'code' => 2]], 500),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.network_taken')));
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
@ -13,6 +14,8 @@
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(function () {
Http::preventStrayRequests();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
@ -41,6 +44,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -87,7 +92,7 @@
->and(data_get(session('instagram_facebook_oauth.pages'), '0.ig_id'))->toBe('ig_1')
->and(data_get(session('instagram_facebook_oauth.pages'), '1.ig_id'))->toBe('ig_2');
Http::assertSentCount(5); // /me + 2 accounts pages + 2 IG lookups
Http::assertSentCount(7); // /me + /me/permissions + 2 accounts pages + /me/businesses + 2 IG lookups
});
test('instagram-facebook callback connects page when first accounts response is empty', function () {
@ -112,6 +117,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -172,6 +179,8 @@
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
@ -223,6 +232,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -291,6 +302,8 @@
$nextUrl = "{$graphApi}/me/accounts?access_token=test-user-token&after=cursor1&limit=100";
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'fb_user', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::sequence()
->push([
@ -334,6 +347,7 @@
'page_access_token' => 'page-token',
'ig_id' => 'ig-new',
'ig_username' => 'mybiz',
'ig_described' => true,
'ig_name' => 'My Biz',
'ig_picture' => null,
],
@ -393,6 +407,7 @@
'page_access_token' => 'fresh-token',
'ig_id' => 'ig-old',
'ig_username' => 'mybiz',
'ig_described' => true,
'ig_name' => 'My Biz',
'ig_picture' => null,
],
@ -451,6 +466,7 @@
'page_access_token' => 'page-token',
'ig_id' => 'ig-new',
'ig_username' => 'mybiz',
'ig_described' => true,
'ig_name' => 'My Biz',
'ig_picture' => null,
],
@ -486,10 +502,19 @@
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock(['user' => $socialiteUser]));
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
'https://graph.facebook.com/*/me/accounts*' => Http::response([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response([
'data' => [
[
'id' => 'page-1',
@ -499,7 +524,7 @@
],
],
], 200),
'https://graph.facebook.com/*' => Http::response([
"{$graphApi}/*" => Http::response([
'id' => 'shared-ig',
'username' => 'shared',
'name' => 'Shared',
@ -509,10 +534,323 @@
$this->actingAs($this->user)
->get(route('app.social.instagram-facebook.callback'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)->where('message', __('accounts.popup_callback.all_connected')));
expect($this->workspace->socialAccounts()
->where('platform', Platform::InstagramFacebook->value)
->exists())->toBeFalse()
->and($this->workspace->socialAccounts()->count())->toBe(1);
});
test('instagram via facebook connects a page reached through a business portfolio', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [
[
'id' => 'page_portfolio',
'name' => 'Portfolio Page',
'picture' => ['data' => ['url' => null]],
'access_token' => 'portfolio-page-token',
'instagram_business_account' => ['id' => 'ig_portfolio'],
],
],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
"{$graphApi}/ig_portfolio*" => Http::response([
'username' => 'portfolio_ig',
'name' => 'Portfolio IG',
], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::InstagramFacebook->value,
'platform_user_id' => 'ig_portfolio',
'username' => 'portfolio_ig',
'status' => Status::Connected->value,
]);
});
test('instagram via facebook describes every page in rounds without serialising them', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
$pages = collect(range(1, 45))->map(fn (int $n) => [
'id' => "page_{$n}",
'name' => "Page {$n}",
'picture' => ['data' => ['url' => null]],
'access_token' => "page-token-{$n}",
'instagram_business_account' => ['id' => "ig_{$n}"],
])->all();
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => $pages], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/ig_*" => Http::response(['username' => 'an_account'], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
$response->assertRedirect(route('app.social.instagram-facebook.select-page'));
expect(session('instagram_facebook_oauth.pages'))->toHaveCount(45);
Http::assertSentCount(4 + 45);
});
test('instagram via facebook says the permission is missing when meta lists a page without a token', function () {
session([
'social_connect_workspace' => $this->workspace->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123');
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_1',
'name' => 'Page',
'picture' => ['data' => ['url' => null]],
'instagram_business_account' => ['id' => 'ig_1'],
]]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
]);
$response = $this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
$response->assertInertia(fn (AssertableInertia $page) => $page
->where('success', false)
->where('message', __('accounts.popup_callback.pages_missing_permission')));
});
test('instagram via facebook does not describe a page it is about to discard', function () {
config()->set('trypost.allow_multiple_social_accounts', true);
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
'platform_user_id' => 'ig_taken',
]);
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [
[
'id' => 'page_taken',
'name' => 'Already Connected',
'access_token' => 'taken-token',
'instagram_business_account' => ['id' => 'ig_taken'],
],
[
'id' => 'page_free',
'name' => 'Still Free',
'access_token' => 'free-token',
'instagram_business_account' => ['id' => 'ig_free'],
],
]], 200),
"{$graphApi}/ig_free*" => Http::response(['username' => 'free_account'], 200),
]);
$this->actingAs($this->user)
->get(route('app.social.instagram-facebook.callback'))
->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_taken'));
expect(SocialAccount::where('platform_user_id', 'ig_free')->sole()->username)->toBe('free_account');
});
test('instagram via facebook falls back to the username when meta returns a null name', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_1',
'name' => 'Page',
'access_token' => 'page-token',
'instagram_business_account' => ['id' => 'ig_1'],
]]], 200),
"{$graphApi}/ig_1*" => Http::response(['username' => 'only_a_handle', 'name' => null], 200),
]);
$this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
expect(SocialAccount::where('platform_user_id', 'ig_1')->sole()->display_name)->toBe('only_a_handle');
});
test('instagram via facebook falls back to the page name when the lookups run out of time', function () {
config()->set('trypost.meta_page_walk_seconds', 0);
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_1',
'name' => 'The Page Name',
'access_token' => 'page-token',
'instagram_business_account' => ['id' => 'ig_1'],
]]], 200),
]);
$this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
$account = SocialAccount::where('platform_user_id', 'ig_1')->sole();
expect($account->display_name)->toBe('The Page Name')
->and($account->username)->toBeNull();
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_1'));
});
test('a reconnect keeps the handle it had when the lookup never ran', function () {
config()->set('trypost.meta_page_walk_seconds', 0);
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::InstagramFacebook,
'platform_user_id' => 'ig_1',
'username' => 'the_handle_we_had',
'avatar_url' => 'avatars/kept.jpg',
]);
session([
'social_connect_workspace' => $this->workspace->id,
'social_reconnect_id' => $account->id,
]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->token = 'test-user-token';
Socialite::shouldReceive('driver')
->with('facebook')
->andReturn(Mockery::mock()
->shouldReceive('usingGraphVersion')->andReturnSelf()
->shouldReceive('redirectUrl')->andReturnSelf()
->shouldReceive('user')->andReturn($socialiteUser)
->getMock());
$graphApi = config('trypost.platforms.instagram-facebook.graph_api');
Http::fake([
"{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200),
"{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => []], 200),
"{$graphApi}/me/accounts*" => Http::response(['data' => [[
'id' => 'page_1',
'name' => 'The Page',
'access_token' => 'fresh-token',
'instagram_business_account' => ['id' => 'ig_1'],
]]], 200),
]);
$this->actingAs($this->user)->get(route('app.social.instagram-facebook.callback'));
$account->refresh();
expect($account->username)->toBe('the_handle_we_had')
->and($account->getRawOriginal('avatar_url'))->toBe('avatars/kept.jpg')
->and($account->access_token)->toBe('fresh-token');
});

View file

@ -116,7 +116,7 @@
$graphApi = 'https://graph.facebook.com/v25.0';
$nextUrl = "{$graphApi}/me/accounts?access_token=secret-token&after=cursor1&limit=100";
Log::shouldReceive('error')->once()->withArgs(function (string $message, array $context) {
Log::shouldReceive('warning')->once()->withArgs(function (string $message, array $context) {
return $message === 'Meta Graph pagination request failed'
&& ! str_contains((string) data_get($context, 'url'), 'secret-token')
&& str_contains((string) data_get($context, 'url'), 'access_token=[REDACTED]');
@ -144,7 +144,7 @@
test('graph paginator throws when the first request fails', function () {
Http::preventStrayRequests();
Log::shouldReceive('error')->once()->withArgs(function (string $message) {
Log::shouldReceive('warning')->once()->withArgs(function (string $message) {
return $message === 'Meta Graph pagination request failed';
});

View file

@ -0,0 +1,519 @@
<?php
declare(strict_types=1);
use App\Exceptions\Social\IncompleteMetaGraphPaginationException;
use App\Services\Social\Meta\ManagedPageList;
use App\Services\Social\Meta\ManagedPages;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
const MANAGED_PAGES_FIELDS = 'id,name,access_token';
beforeEach(function () {
Http::preventStrayRequests();
Log::spy();
});
function managedPagesGraphApi(): string
{
return (string) config('trypost.platforms.facebook.graph_api');
}
function managedPagesWalk(array $extraFakes = [], array $granted = ['business_management']): ManagedPageList
{
Http::fake($extraFakes);
return ManagedPages::forUser(managedPagesGraphApi(), 'user-token', MANAGED_PAGES_FIELDS, $granted);
}
function managedPagesIds(ManagedPageList $walk): array
{
return collect($walk->pages)->pluck('id')->all();
}
test('business portfolio pages are found when me/accounts is empty', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Owned', 'access_token' => 'owned-token']],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response([
'data' => [['id' => 'page_2', 'name' => 'Client', 'access_token' => 'client-token']],
], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1', 'page_2'])
->and($walk->complete)->toBeTrue();
});
test('a page listed in both me/accounts and a portfolio is returned once, keeping its user token', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'portfolio-token']],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect($walk->pages)->toHaveCount(1)
->and(data_get($walk->pages, '0.access_token'))->toBe('role-token');
});
test('a page reached with a token wins over the same page reached without one', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'No Token Here']],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Same Page', 'access_token' => 'portfolio-token']],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect($walk->pages)->toHaveCount(1)
->and(data_get($walk->pages, '0.access_token'))->toBe('portfolio-token')
->and(ManagedPages::publishable($walk->pages))->toHaveCount(1);
});
test('every page meta lists is returned, token or not', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [
['id' => 'page_1', 'name' => 'No Access'],
['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'],
],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect(collect($walk->pages)->pluck('id')->sort()->values()->all())->toBe(['page_1', 'page_2']);
});
test('only the pages carrying a token are publishable', function () {
$publishable = ManagedPages::publishable([
['id' => 'page_1', 'name' => 'No Access'],
['id' => 'page_2', 'name' => 'Usable', 'access_token' => 'page-token'],
['id' => 'page_3', 'name' => 'Empty Token', 'access_token' => ''],
]);
expect(collect($publishable)->pluck('id')->all())->toBe(['page_2']);
});
test('a login meta reports as refusing business_management never touches the portfolio edges', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
], granted: ['pages_show_list']);
expect($walk->pages)->toHaveCount(1)
->and($walk->complete)->toBeTrue();
Http::assertSentCount(1);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/me/businesses'));
});
test('a refused portfolio index says nothing about the pages behind it', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response([
'error' => ['message' => 'Requires business_management permission', 'code' => 200],
], 403),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});
test('a throttled portfolio index leaves the walk unable to vouch for itself', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response([
'error' => ['message' => 'Application request limit reached', 'code' => 4],
], 400),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});
test('the walk gives up on time rather than outliving the request', function () {
config()->set('trypost.meta_page_walk_seconds', 0);
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
Http::assertNotSent(fn ($request) => str_contains($request->url(), '_pages'));
});
test('a refused single edge is an answer about that edge, and leaves the walk complete', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Owned', 'access_token' => 'token-1']],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response([
'error' => ['message' => 'permission denied', 'code' => 10],
], 403),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeTrue();
});
test('a continuation cut short keeps the pages it already read', function () {
$graphApi = managedPagesGraphApi();
$cursor = "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=";
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::sequence()
->push([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
'paging' => ['next' => "{$cursor}c1"],
], 200)
->push([
'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']],
'paging' => ['next' => "{$cursor}c2"],
], 200)
->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1', 'page_2'])
->and($walk->complete)->toBeFalse();
});
test('the cursor budget counts requests, not edges', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_x', 'name' => 'X', 'access_token' => 'token']],
'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=forever"],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect($walk->complete)->toBeFalse();
expect(collect(Http::recorded())->filter(
fn (array $pair) => str_contains($pair[0]->url(), 'after=forever'),
))->toHaveCount(ManagedPages::MAX_CONTINUATIONS);
});
test('a throttled portfolio edge keeps the pages it has and admits it is incomplete', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'error' => ['message' => 'Application request limit reached', 'code' => 4],
], 400),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});
test('an upstream failure listing portfolios keeps the me/accounts pages', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['error' => ['message' => 'oops']], 500),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});
test('a failing me/accounts still aborts instead of reporting no pages', function () {
$graphApi = managedPagesGraphApi();
managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['error' => ['message' => 'fail']], 400),
]);
})->throws(IncompleteMetaGraphPaginationException::class);
test('pages spread across several portfolios are all collected', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response([
'data' => [['id' => 'biz_1'], ['id' => 'biz_2']],
], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
"{$graphApi}/biz_2/owned_pages*" => Http::response(['data' => []], 200),
"{$graphApi}/biz_2/client_pages*" => Http::response([
'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']],
], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1', 'page_2'])
->and($walk->complete)->toBeTrue();
});
test('a paginated portfolio edge is followed to the end', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::sequence()
->push([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor1"],
], 200)
->push([
'data' => [['id' => 'page_2', 'name' => 'Two', 'access_token' => 'token-2']],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1', 'page_2'])
->and($walk->complete)->toBeTrue();
});
test('a cursor that fails after the first page keeps that page and admits it is incomplete', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::sequence()
->push([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor1"],
], 200)
->push(['error' => ['message' => 'Invalid cursor', 'code' => 100]], 400),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});
test('a portfolio entry without an id is skipped', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['name' => 'No Id']]], 200),
]);
expect($walk->pages)->toHaveCount(1)
->and($walk->complete)->toBeTrue();
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'owned_pages'));
});
test('more portfolios than the walk reads is an incomplete walk, not a failed one', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response([
'data' => [['id' => 'biz_1']],
'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"],
], 200),
"{$graphApi}/biz_1/*_pages*" => Http::response(['data' => []], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});
test('the portfolio list is read in one request, never paginated', function () {
$graphApi = managedPagesGraphApi();
managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response([
'data' => [['id' => 'biz_1']],
'paging' => ['next' => "{$graphApi}/me/businesses?access_token=user-token&after=cursor1"],
], 200),
"{$graphApi}/biz_1/*_pages*" => Http::response(['data' => []], 200),
]);
expect(collect(Http::recorded())->filter(
fn (array $pair) => str_contains($pair[0]->url(), '/me/businesses'),
))->toHaveCount(1);
});
test('portfolio edges are read concurrently rather than one after another', function () {
$graphApi = managedPagesGraphApi();
$portfolios = collect(range(1, 30))->map(fn (int $n) => ['id' => "biz_{$n}"])->all();
managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200),
"{$graphApi}/*_pages*" => Http::response(['data' => []], 200),
]);
Http::assertSentCount(2 + (30 * 2));
});
test('pages from every round survive the merge, not just the first', function () {
$graphApi = managedPagesGraphApi();
$portfolios = collect(range(1, 26))->map(fn (int $n) => ['id' => "biz_{$n}"])->all();
$fakes = [
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => $portfolios], 200),
];
foreach (range(1, 26) as $n) {
$fakes["{$graphApi}/biz_{$n}/owned_pages*"] = Http::response([
'data' => [['id' => "page_{$n}", 'name' => "Page {$n}", 'access_token' => "token-{$n}"]],
], 200);
$fakes["{$graphApi}/biz_{$n}/client_pages*"] = Http::response(['data' => []], 200);
}
$walk = managedPagesWalk($fakes);
expect($walk->pages)->toHaveCount(26)
->and(collect($walk->pages)->pluck('id')->sort()->values()->all())
->toBe(collect(range(1, 26))->map(fn (int $n) => "page_{$n}")->sort()->values()->all());
});
test('a portfolio edge paging off-host never gets the token', function () {
$graphApi = managedPagesGraphApi();
managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
'paging' => ['next' => 'https://evil.example/owned_pages?access_token=user-token'],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example'));
});
test('an off-host cursor stops the edge instead of re-reading it', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200),
"{$graphApi}/biz_1/owned_pages*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
'paging' => ['next' => 'https://evil.example/owned_pages?access_token=user-token'],
], 200),
"{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'evil.example'));
expect(collect(Http::recorded())->filter(
fn (array $pair) => str_contains($pair[0]->url(), 'owned_pages'),
))->toHaveCount(1);
});
test('the cursor budget stops a walk that would never end', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response(['data' => []], 200),
"{$graphApi}/me/businesses*" => Http::response([
'data' => collect(range(1, ManagedPages::MAX_CONTINUATIONS + 5))
->map(fn (int $n) => ['id' => "biz_{$n}"])
->all(),
], 200),
"{$graphApi}/*_pages*" => Http::response([
'data' => [['id' => 'page_x', 'name' => 'X', 'access_token' => 'token']],
'paging' => ['next' => "{$graphApi}/biz_1/owned_pages?access_token=user-token&after=cursor"],
], 200),
]);
expect($walk->complete)->toBeFalse();
});
test('the deadline stops me/accounts from paginating forever', function () {
config()->set('trypost.meta_page_walk_seconds', 0);
$graphApi = managedPagesGraphApi();
managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'One', 'access_token' => 'token-1']],
'paging' => ['next' => "{$graphApi}/me/accounts?access_token=user-token&after=c1"],
], 200),
]);
})->throws(IncompleteMetaGraphPaginationException::class);
test('a pages-api throttle on a user token is a throttle, not an answer', function () {
$graphApi = managedPagesGraphApi();
$walk = managedPagesWalk([
"{$graphApi}/me/accounts*" => Http::response([
'data' => [['id' => 'page_1', 'name' => 'Page', 'access_token' => 'role-token']],
], 200),
"{$graphApi}/me/businesses*" => Http::response([
'error' => ['message' => 'Page request limit reached', 'code' => 32],
], 400),
]);
expect(managedPagesIds($walk))->toBe(['page_1'])
->and($walk->complete)->toBeFalse();
});