trypost/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php
Hafiz Muhammad Moaz 91c3d86d86
Allow multiple social accounts per network via env (#286)
* feat: expose self-hosted mode to the accounts UI

SocialAccountObserver already bypasses the one-account-per-network
guard when trypost.self_hosted is true, but the frontend had no way
to know that and always collapsed a network to a single card once
any account existed - so self-hosted deployments could not surface
a second LinkedIn (or Instagram) connection even though the backend
would allow creating it.

* feat: allow connecting multiple accounts per network when self-hosted

NetworkConnectGrid always collapsed a network (LinkedIn profile/page,
Instagram standalone/Facebook) to a single card once any account
existed, with no way to trigger another OAuth flow - even though
SocialAccountObserver already allows unlimited accounts per network
in self-hosted mode. A self-hoster connecting their personal LinkedIn
profile had no path back to the connect flow to also add a company
page (or a second company page/showcase page).

Render one card per connected account instead of collapsing to the
first, and keep a standing "Connect another" card available for a
network's existing connections when self-hosted. Hosted mode is
unchanged: still one card per network, matching the backend's
still-enforced one-account-per-network limit there.

* test: cover the selfHosted prop on accounts and onboarding pages

Backend behavior for connecting a second identity per network in
self-hosted mode was already covered (LinkedInControllerTest,
NetworkUniquenessTest) - these just confirm the new prop the frontend
now depends on is actually present and reflects config correctly.

* style: apply prettier formatting

Pre-existing drift in this file unrelated to the selfHosted change.

* refactor: read selfHosted from shared Inertia props

The flag is already shared by HandleInertiaRequests, so the accounts
and onboarding controllers do not need to pass it again.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: gate multiple social accounts with a dedicated env

Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: tighten multiple-account gates after review

Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: bind reconnect to the card and unique social identity

Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: build the OAuth URL before opening the popup

Keep the popup opener URL-only so reconnect query params are assembled at the call site.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: drop dead social-account guards and slim the connect grid

Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: scope social reconnect to the current network

Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: slim connectable-identity filtering

Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: slim social identity persist helpers

Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep reconnect updates on the original social card

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: run the suite with multiple social accounts enabled

phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the
true value in .env.ci. That broke eight tests across Automation, MCP,
PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only
needed two accounts of one network as a fixture, not as a rule under test.

Match .env.ci instead. Every test that exercises the one-per-network rule
already sets the config itself; the accounts index test was the only one
leaning on the implicit default, so it now pins it.

* fix: align the multi-account fallback with the self-hosted default

allow_multiple_social_accounts fell back to env('SELF_HOSTED', false)
while self_hosted itself defaults to env('SELF_HOSTED', true). A
self-hosted install that never wrote SELF_HOSTED to its .env resolved to
false and silently lost multiple accounts per network on upgrade, which
is the opposite of what the documented fallback promises.

* fix: collapse duplicate identities before adding the unique index

Installs predating the index can hold the same identity twice: the
network guard was bypassed for multi-account installs and Pinterest
always created a fresh row. Creating the index on that data aborts
migrate mid-deploy.

Keep the newest row per identity and move its post_platforms over before
dropping the duplicates - the FK is nullOnDelete, so deleting outright
would orphan drafts and scheduled posts.

* fix: refuse a reconnect that authorized a different identity

connectIdentity overwrote platform_user_id with whatever the provider
returned, so reconnecting a card while signed into another account
repointed the row - and every draft and scheduled post bound to it - at
a stranger. LinkedIn guarded this at the controller and Facebook via its
filtered page list; nothing covered X, TikTok, Threads, Discord,
Bluesky, Mastodon, Pinterest, Instagram or Telegram.

Enforce the identity match at the single choke point every connect flow
goes through. Every call site already maps NetworkAlreadyConnectedException
to network_taken, so the refusal surfaces without new plumbing.

Also restore the null-platform guard in the observer: occupiesNetwork
type-hints a non-nullable Platform, so a row without one died with a
TypeError instead of the database's NOT NULL error.

* fix: filter connectable identities on every picker step

YouTubeController::select re-fetched the channels and matched the posted
id straight off the raw list, unlike callback and selectChannel. With a
live youtube_oauth session it let a POST name any channel the Google
account owns and bind it to the reconnect target. It also read the
reconnect from the session while the connect below it read
youtube_oauth.reconnect_id, so the two could disagree - pass the
resolved account through instead.

filterConnectableIdentities also short-circuited in multi-account mode,
and the unique index is scoped to platform rather than network. That let
one Instagram account connect twice, once directly and once via
Facebook, publishing every Instagram post to it twice. The existing
except() already spans networkPlatformValues(), so dropping the
short-circuit closes it.

* refactor: type the connect cards and drop the dead accounts grid

The cards computed inferred account as a required ConnectedAccount and
then pushed undefined onto it (TS2345). CI only runs eslint so it stayed
green, but vue-tsc and editors flag it.

SocialAccountsGrid is referenced nowhere; its reconnect button was
updated in this branch without passing the card id, which would have
been a bug had anything rendered it.

* test: keep the suite on the cloud one-account-per-network default

CI runs the Cloud build, so the suite baseline should be the Cloud
default rather than the self-hosted one. Put phpunit.xml and .env.ci
back to false and make the eight tests that merely need two accounts of
one network as a fixture opt in for themselves.

This also un-deads the config()->set(true) calls the branch had already
added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and
SocialAccountObserverTest, which the forced true had turned into no-ops.

* fix: connect standalone instagram instead of reopening the picker

The picker emits an already-resolved connect method, but this branch
rewired @select from openOAuthPopup to startConnect. startConnect sends
a bare 'instagram' straight back into its own picker branch, so choosing
"Instagram" closed the dialog and immediately reopened it - the OAuth
window never opened and the standalone flow was unreachable. Only the
via-Facebook button still worked.

Split the URL-opening tail out of startConnect and let the dialog call
that directly.

* fix: reject a telegram reconnect before burning the connect code

The nonce was consumed before connectIdentity ran, so posting /connect in
the wrong chat spent the one-off code and forced the user to generate a
new one. Check the identity first and report wrong_chat instead of
network_taken, which told them to disconnect an account when the real fix
was posting in the channel they were reconnecting.

* fix: leave one target per post when merging duplicate accounts

post_platforms has no unique on (post_id, social_account_id), so a post
holding a row per duplicate account ended up with two enabled rows aimed
at the surviving account and would publish to it twice. Keep one row per
post, preferring a published one so history survives.

* refactor: collapse the repeated connect-flow boilerplate

Four shapes were copy-pasted across the connect controllers:

- the session + permission guard opening 16 actions, now connectWorkspace()
  throwing a ConnectPopupException that renders the popup itself
- the reconnected/connected ternary in 13 places, now connectedCallback()
- the "nothing left to connect" branch in 4 places, now
  noConnectableIdentities()
- validatedReconnectId() re-querying what reconnectAccount() already does

Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect
account three or four times per callback; it is resolved once and passed
down. The three GET pickers skipped the manageAccounts check their POST
siblings had, and pick it up from the shared guard.

Drops the color key from connectableOptions and the matching frontend
field - nothing read it. Platform::color() stays; the disconnection
emails use it.

* fix: keep an expired connect popup out of the error log

ConnectPopupException escapes to the framework handler so it can render
itself, which also meant report() ran first: every session_expired and
workspace_not_found popup filed an ERROR and a Nightwatch issue for what
used to be a silent return. A stale popup is a normal outcome, so it now
implements ShouldntReport.

The Mastodon and Threads guards also cleared their provider session
after connectWorkspace(), so a workspace that vanished mid-flow left the
client secret and the OAuth state behind. Clear first, then resolve.
clearMastodonSession() no longer touches social_connect_workspace -
whatever closes the popup already does.

* fix: stop telling users to disconnect an account that is not the problem

Two flows reused popup_callback.network_taken - "This workspace already
has an account for this network. Disconnect it first." - for situations
where that is neither true nor actionable:

- reconnecting a card while signed into a different account on the
  provider, now wrong_account
- an empty picker in multi-account mode, where every page or channel on
  that login is simply already connected, now all_connected

NetworkAlreadyConnectedException carries the message key so the catch
sites stay one line. handleCallback() also drops its $platform argument;
it read $this->platform for the reconnect lookup and the identity filter
either way, so a caller passing a different platform would have scoped
the lookup to the wrong network.

* refactor: filter linkedin identities with the shared helper

The picker hand-rolled its own reconnect narrowing because the profile
and the pages arrive in two different shapes. Flatten them into one pool
of LinkedIn identities, run the shared filter, and split them again for
the view - the same path Facebook, YouTube and Instagram already take.

Side effect worth having: the picker previously only narrowed on a
reconnect, so it would offer an identity that is already connected and
only fail once the user picked it. It now hides taken identities up
front and says so when nothing is left.

* fix: keep the linkedin picker's own empty state

Routing the picker through the shared filter made every empty pool look
like "nothing left to take", including the pool LinkedIn never filled.
A self-hoster running pages-only who administers no page was told the
network was already connected, or that every account on the login was
taken - both false - and the picker's own "you are not an admin of any
LinkedIn page" state became unreachable.

Only treat it as taken when filtering is what emptied it. Splitting the
pool back also compared the person id loosely on one side and strictly
on the other; one predicate now drives both.

Threads had two forget() calls for a key the top of the action already
clears, and YouTube's picker resolved the reconnect account twice on the
failure path.

* fix: keep the enabled row when collapsing duplicate post targets

SyncPostPlatforms seeds a disabled post_platforms row for every account
in the workspace, so the usual duplicate is one row the user actually
checked next to one they never saw - both pending, both created in the
same second. Ordering only by published-then-newest made that a coin
flip, and PublishPost iterates enabled() only, so half the time a
scheduled post would silently stop reaching that account and take its
caption and per-platform meta with it. This runs once against production
data and the dropped row is gone, so enabled now beats disabled.

Also: the empty-pool exit from the LinkedIn picker was the only one
leaving linkedin_pending - and its tokens - in the session. The
rationale comments move to the docblocks they belong in, and usePage()
comes out of the cards computed.

* fix: stop the migration destroying publish history and automations

Two ways the one-shot merge lost data that cannot be rebuilt:

Surplus published post_platforms rows were deleted. Two duplicate
accounts really could each have published, and each row carries the
platform_post_id for a live post on the network - dropping one leaves
that post unmanageable and invisible to metrics. The docblock claimed
published beat everything; now the code does, and only unpublished
repeats collapse.

Automation nodes persist social_account_id inside a JSON column with no
foreign key, so deleting the loser left RunGenerateNode skipping that
target, or generating nothing at all when it was the node's only
account. The ids are rewritten - current and legacy shapes both - and
entries the merge just turned into duplicates are collapsed.

Ordering is now total (null created_at sorts oldest on every engine,
then id) so a rehearsal on a replica keeps the same rows as the real
run. The LinkedIn picker also passes onboardingProgress inline: it
clears linkedin_pending on the empty path, and a deferred reload would
re-GET the route and swap the empty state for a session-expired popup.

* fix: make the identity merge auditable and stop a second delivery

Self-hosted installs run this unattended and it cannot be undone, so
each collapsed group now logs the workspace, the identity, which row was
kept, which were dropped, and how many post_platforms and automations it
touched. down() says plainly that it drops the index only.

Two narrower fixes:

A post holding a published row plus an enabled unpublished row for the
same account kept both, and PostPlatform::scopeEnabled() filters on
`enabled` alone with no status check - so a republish would deliver the
same content to that identity twice. Once a published row exists, every
unpublished repeat goes.

The automation dedupe ran on every automation in the workspace, not just
the ones the merge rewrote. A node legitimately holding two entries for
one account under different content types would be collapsed to
whichever came first in the array. It now runs only where an id was
actually substituted.

* test: rehearse the identity merge against a messy database

Every test on this migration so far covered a case someone thought to
write, which is why three separate review rounds each found a defect the
earlier ones missed. This builds a deliberately messy database instead -
three workspaces, four networks, one to three copies of each identity,
posts mixing published, pending and failed rows across the duplicates
with enabled flags varying, and automations referencing them in both the
current and legacy JSON shapes - then runs the real migration and
asserts what must be true afterwards rather than what happens to a
particular fixture.

Invariants: no duplicate identity survives, no published row is ever
destroyed, no post ends up enabled twice against one account, nothing in
post_platforms or automations points at a deleted account, and the
newest row of each identity is the one kept.

The generator is seeded, so a failure reproduces, and it asserts its own
output is adversarial - roughly nine duplicate groups and fourteen
published rows - so it cannot quietly degrade into passing on an empty
problem. Verified by mutation: dropping the automation repoint, the
published guard, or the repeated-target collapse each fails exactly the
invariant that covers it.

* fix: stop the youtube picker refetching itself into a cleared session

HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding
- exactly the people connecting their first accounts - so Inertia
re-GETs the picker route right after it mounts. For Facebook and
Instagram that re-entry is harmless and deliberately left deferred, but
YouTube calls the Google API again, and fetchChannels() turns any
failure into an empty list that clears the connect session and swaps the
mounted picker for an error the user cannot retry from. Same guard the
LinkedIn picker already got.

LinkedIn also answered a reconnect that authorized a different identity
with "Page not found", including in the person branch where no page is
involved. Every other platform says wrong_account, which this PR added.

* refactor: drop the unreachable youtube channel picker

Google's own delegation screen already lists every channel on the
account and makes the user pick one before it issues the token, so
channels?mine=true always answers with that single channel and
count($channels) === 1 always won. The picker behind it was never
reached - its Vue page was deleted back in 7c00c338 (January) and
nothing broke, which is the clearest evidence it was dead.

Removes selectChannel(), select(), both routes, the youtube_oauth
session payload and the tests that drove them. If Google ever does
return more than one, the callback connects the first and logs a warning
rather than routing to a screen that no longer exists.

* fix: serialize connects so two popups cannot seat one network twice

The observer's occupiesNetwork() is a check-then-insert with nothing
holding the gap, and the new unique index covers the identity, not the
network. Two tabs finishing OAuth at the same moment for *different*
identities on one network both passed the exists() check and both
inserted, leaving a Cloud workspace with the two accounts the rule
exists to prevent. The same-identity race was already safe - the unique
violation is caught and re-queried.

A database constraint cannot hold this: allow_multiple_social_accounts
is a runtime flag, so the rule is on for Cloud and off for self-hosted,
and an index cannot read config. Lock per workspace and network instead,
the way markAsDisconnected() and ConnectionVerifier already do.

This covers connectIdentity(), which every OAuth flow and the Telegram
action go through. A direct create() still answers to the observer
alone, and a self-hosted install running file cache across several nodes
locks per node.

* fix: handle a busy connect lock on the telegram path

Every other caller funnels LockTimeoutException into its generic
\Exception catch and closes the popup with error_connecting. Telegram
has no such catch, so the new lock could 500 the webhook - and because
the nonce is spent before connectIdentity runs, Telegram's retry of the
same update short-circuits on the consumed code and returns without
dispatching anything. The dialog would spin forever on a code that can
no longer be used.

Also restores coverage the picker removal dropped: the deleted select
tests were the only ones driving a multi-channel response, so nothing
exercised the reconnect narrowing to its own card, or multi-account mode
skipping an already-connected channel. Both are back against the
callback, and removing the narrowing in filterConnectableIdentities
fails them.

* fix: stop the instagram login seating an account already held via facebook

filterConnectableIdentities() drops every identity already connected on the
network, which is what keeps one Instagram account from being seated twice
under its two platforms. Every flow that persists an identity ran it except
the direct Instagram Login callback, so the guard only held in one direction:
InstagramFacebookController refused an account already connected as
`instagram`, but the reverse was allowed through.

With multiple accounts per network enabled the observer's network check is
bypassed and the unique index does not span platforms, so authorizing the
same account through the direct flow created a second row. Both then seed a
post_platform row and the post goes out twice to one account.

* fix: name the real reason when a linkedin profile reconnect switches member

Reconnecting a card narrows the authorized identities to that card's own, so
authorizing a different LinkedIn login empties the pool. selectIdentity()
reported that as "Page not found." for every card, including personal
profiles where no page was ever involved.

A profile reconnect has no page to be missing: an empty pool there can only
mean this login is a different member. Say so with the wrong_account wording
select() already uses for the same condition. Page reconnects keep
page_not_found, where the organization really can be absent from the login.

* fix: surface the busy telegram connect instead of a generic failure

The connect lock timing out dispatches its own 'busy' reason so the dialog
can tell the user to retry, but the dialog only mapped network_taken and
wrong_chat and fell back to error_generic for everything else. The reason
reached the browser and died there, leaving "Could not start the connection"
for a case that just needs another moment.

* test: cover reconnect on every flow that gained it

rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and
Bluesky a reconnect path they did not have before — TikTok had been actively
clearing social_reconnect_id on connect — and none of them had a test for it.
Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did.

Each now covers both halves: authorizing the same identity refreshes the
existing card and reports it as a reconnect, and authorizing a different one
is refused with wrong_account instead of quietly seating a stranger on the
card and every post scheduled against it.

* fix: repair what a reconnect leaves behind when it cannot proceed cleanly

Two things connectIdentity got wrong once the reconnect path existed.

A reconnect through the other variant of a network moves the card to the new
platform — same identity, different API flavor. Post targets carry their own
platform snapshot, and that snapshot picks the publisher, the queue and the
scopes checked before publishing. Left behind, it failed every pending post on
a permission the account no longer needs: an Instagram card moved to the
Facebook variant still demanded instagram_business_content_publish and stopped
with "Missing permissions". Pending targets now follow the card and reset a
content type the new platform cannot publish; published targets keep theirs,
since they record what really went out under a platform_post_id from that API.

The network lock timing out also arrived as a raw LockTimeoutException, which
every OAuth callback filed through its generic catch: an error log and "Error
connecting account" for the exact race the lock exists to absorb. It now
carries a busy messageKey through the branch each flow already handles, the
same way the Telegram path already reported it.

* refactor: resolve the linkedin reconnect card once per select

select() already looked the card up before deciding whether the chosen
identity matches it, then connectPerson() and connectOrganization() looked it
up again on their own — two identical queries per submit, and two places that
could disagree about what is being reconnected. The caller passes what it
already holds.

* test: render the grid's multi-account branch

phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test
overrode it, so the card the flag exists to add never rendered anywhere. The
pair pins both sides: a taken network offers no second card when multiples are
off, and offers one when they are on.

* test: pin why the linkedin select guards exist

connectIdentity() already refuses a mismatched reconnect and answers with the
same wrong_account message, so every existing test passes with the two guards
in select() deleted — which is exactly how they would get deleted. What they
actually buy is skipping the avatar download that building the connect payload
runs first.

Both now assert the fetch never happens, so the guards fail loudly instead of
looking redundant.

* fix: carry retrying targets through a variant move, atomically

Two holes in the move added a commit ago.

It only carried pending targets, but a retrying one is not finished either —
the publish job reschedules itself and reads the snapshot fresh on the next
attempt, so leaving it behind meant it retried against the old variant until
it exhausted its budget on a permission the account no longer needs. Failed
and published targets stay put; a publishing one has a job mid-flight already
working from the snapshot it read.

The card and its targets also moved in three separate statements, so a crash
between them left exactly the split this was meant to close. They share a
transaction now.

* chore: drop the dusk selectors nothing reads

Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase,
no browse(). Browser tests run on pest-plugin-browser driving Playwright, and
its @selector resolves to data-testid. The 45 dusk attributes left across 18
components selected nothing.

CLAUDE.md was the reason they kept coming back: it told every agent to add
them. Its browser-testing section now describes the setup that exists —
data-testid targeting, the wait helper these tests need because assertions do
not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real.

Verified before removing: every @selector used in tests/Browser resolves to a
data-testid, seven of them through bound :data-testid, so none depended on a
dusk attribute.

* chore: drop the last one-account-per-network helper

hasConnectedPlatform() has no callers left anywhere — app, tests, views or
routes. It sat directly above getSocialAccount(), which this branch already
removed, and is the same leftover from when a workspace could hold one account
per platform.

---------

Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:28:14 -03:00

1584 lines
62 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Status as SocialAccountStatus;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Jobs\VerifyUpcomingPostConnections;
use App\Mail\PostAtRisk;
use App\Models\Notification;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
test('marks the account expired and queues a notification when verify throws TokenExpiredException', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
expect($account->fresh()->status)->toBe(SocialAccountStatus::TokenExpired);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($workspace, $postPlatform) {
return $mail->workspace->id === $workspace->id
&& count($mail->postPlatformIds) === 1
&& in_array($postPlatform->id, $mail->postPlatformIds, true);
});
});
test('creates an in-app notification for the workspace owner alongside the email', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect(Notification::count())->toBe(1);
$notification = Notification::first();
expect($notification->user_id)->toBe($workspace->owner->id)
->and($notification->workspace_id)->toBe($workspace->id)
->and($notification->type)->toBe(Type::PostAtRisk)
->and($notification->channel)->toBe(Channel::Both)
->and($notification->title)->toBe('1 upcoming post is at risk');
// The in-app title and the email subject are built from the same count,
// captured once at dispatch time — they must never disagree.
Mail::assertQueued(PostAtRisk::class, fn ($mail) => $mail->count === 1);
});
test('defers to the next run instead of warning when markAsTokenExpired loses the account status lock', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
// Simulate a concurrent process (e.g. a publish attempt) already holding
// this account's status lock, so markAsTokenExpired() can't acquire it
// and silently no-ops.
$lock = Cache::lock("social_account_status:{$account->id}", 10);
$lock->get();
try {
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($account->fresh()->status)->toBe(SocialAccountStatus::Connected);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
} finally {
$lock->release();
}
});
test('verifies a distinct account only once even with multiple at-risk posts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->facebook()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$postPlatforms = collect(range(1, 3))->map(function (int $i) use ($workspace, $account) {
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20 * $i),
]);
return PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
});
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Facebook access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
foreach ($postPlatforms as $postPlatform) {
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
}
Mail::assertQueued(PostAtRisk::class, fn ($mail) => count($mail->postPlatformIds) === 3);
});
test('ignores posts outside the 1-hour window', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addHours(3),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
Mail::assertNothingQueued();
});
test('ignores draft posts even with a scheduled_at inside the window', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->draft()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
Mail::assertNothingQueued();
});
test('ignores posts already warned', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
'connection_warning_sent_at' => now()->subMinutes(5),
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
Mail::assertNothingQueued();
});
test('does not re-verify an account already known token_expired, but still warns about new posts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
'error_message' => 'Threads access token is invalid or expired',
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
Mail::assertQueued(PostAtRisk::class);
});
test('counts distinct posts, not post_platforms, when one post spans multiple broken accounts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$accountOne = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
'error_message' => 'Threads access token is invalid or expired',
]);
$accountTwo = SocialAccount::factory()->facebook()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
'error_message' => 'Facebook access token is invalid or expired',
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $accountOne->id,
'platform' => $accountOne->platform,
'status' => PostPlatformStatus::Pending,
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $accountTwo->id,
'platform' => $accountTwo->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
// Two post_platforms, but they belong to the same post — the count
// must reflect distinct posts, not post_platform rows.
Mail::assertQueued(PostAtRisk::class, fn ($mail) => $mail->count === 1 && count($mail->postPlatformIds) === 2);
});
test('does not re-verify an account already known disconnected, but still warns about new posts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Disconnected,
'error_message' => 'Threads account was disconnected',
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
Mail::assertQueued(PostAtRisk::class);
});
test('does not re-notify about an already-broken account within the cooldown, even for a brand-new at-risk post', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
]);
// A post already warned about 10 minutes ago — still within the 1-hour
// renotify cooldown for this account.
$earlierPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(10),
]);
PostPlatform::factory()->create([
'post_id' => $earlierPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
'connection_warning_sent_at' => now()->subMinutes(10),
]);
// A brand-new post that just entered the risk window — never warned.
$newPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$newPostPlatform = PostPlatform::factory()->create([
'post_id' => $newPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
// Left unstamped so it's picked up once the cooldown expires, instead of
// being silently absorbed without ever being mentioned in an email.
expect($newPostPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('re-notifies about an already-broken account once the cooldown has passed', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
]);
$earlierPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(10),
]);
PostPlatform::factory()->create([
'post_id' => $earlierPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
'connection_warning_sent_at' => now()->subMinutes(90),
]);
$newPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$newPostPlatform = PostPlatform::factory()->create([
'post_id' => $newPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($newPostPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
Mail::assertQueued(PostAtRisk::class);
});
test('skips notifying about a freshly-disconnected account to avoid a duplicate with AccountDisconnected', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
'disconnected_at' => now()->subMinutes(2),
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('notifies about an already-broken account once the disconnection grace period has passed', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
'disconnected_at' => now()->subMinutes(10),
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
Mail::assertQueued(PostAtRisk::class);
});
test('trusts a recently successful verification and does not re-verify within the same run window', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
'last_verified_at' => now()->subMinutes(10),
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
Mail::assertNothingQueued();
});
test('re-verifies an account once the last successful verification has aged out', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
'last_verified_at' => now()->subMinutes(56),
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($account->fresh()->last_verified_at->isAfter(now()->subMinute()))->toBeTrue();
});
test('records last_verified_at after a successful verification', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
'last_verified_at' => null,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($account->fresh()->last_verified_at)->not->toBeNull();
});
test('defers verification until the post is within 30 minutes of publishing', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(55),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
expect($account->fresh()->last_verified_at)->toBeNull();
Mail::assertNothingQueued();
});
test('verifies once the nearest post in the group crosses the 30-minute lead, even if others are farther out', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$nearPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(25),
]);
$nearPostPlatform = PostPlatform::factory()->create([
'post_id' => $nearPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$farPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(58),
]);
$farPostPlatform = PostPlatform::factory()->create([
'post_id' => $farPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($account->fresh()->last_verified_at)->not->toBeNull();
expect($nearPostPlatform->fresh())->not->toBeNull();
expect($farPostPlatform->fresh())->not->toBeNull();
});
test('defers to the next run instead of duplicating AccountDisconnected when another process wins the race', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturnUsing(function () use ($account) {
// Simulate RefreshExpiringTokens/RefreshSocialToken discovering and
// announcing the same break (its own AccountDisconnected email)
// between when this job loaded the account as Connected and when
// its own verify() call returns.
$account->fresh()->update([
'status' => SocialAccountStatus::TokenExpired,
'disconnected_at' => now(),
]);
throw new TokenExpiredException('Threads access token is invalid or expired');
});
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('skips notifying when a concurrent run already claimed the warning window', function () {
// This simulates the "other run" serially, inside the same process and
// transaction, by mutating the row from within our own verify() mock —
// it proves the claim query's WHERE clause excludes an already-claimed
// row, not that ->lockForUpdate() itself prevents two real overlapping
// Postgres transactions from double-claiming. That needs two genuinely
// concurrent connections, which Pest's single-connection RefreshDatabase
// tests can't exercise.
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturnUsing(function () use ($postPlatform) {
// Simulate a second, overlapping run of this same job (the
// ShouldBeUnique lock's TTL matches the schedule cadence, so two
// instances can briefly overlap if a run takes unusually long)
// already claiming and warning about this exact post_platform,
// between this run's select and its own claim update below.
$postPlatform->update(['connection_warning_sent_at' => now()]);
throw new TokenExpiredException('Threads access token is invalid or expired');
});
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($account->fresh()->status)->toBe(SocialAccountStatus::TokenExpired);
Mail::assertNothingQueued();
});
test('narrows the notification to only the rows this run actually claimed when a concurrent run claims some but not all', function () {
// Same caveat as the test above: the "other run" is simulated serially
// inside our own verify() mock, so this proves the post-claim narrowing
// logic, not ->lockForUpdate()'s row-locking behavior under two genuinely
// concurrent Postgres transactions (not reproducible with a single test
// connection).
Mail::fake();
// Both post_platforms belong to the SAME account/group deliberately —
// unlike a two-account version, this makes the scenario independent of
// which group a DB result set happens to return first (groupBy()
// preserves query order, and atRiskPostPlatforms() has no ORDER BY).
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$claimedByOtherRunPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$claimedByOtherRunPostPlatform = PostPlatform::factory()->create([
'post_id' => $claimedByOtherRunPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$stillClaimableByThisRunPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(25),
]);
$stillClaimableByThisRunPostPlatform = PostPlatform::factory()->create([
'post_id' => $stillClaimableByThisRunPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->once()
->andReturnUsing(function () use ($claimedByOtherRunPostPlatform) {
// Simulate a second, overlapping run of this same job claiming
// one of this account's two post_platforms — mid-call, between
// this run's own read (already reflected in $atRisk) and its
// claim step below.
$claimedByOtherRunPostPlatform->update(['connection_warning_sent_at' => now()]);
throw new TokenExpiredException('Threads access token is invalid or expired');
});
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($stillClaimableByThisRunPostPlatform, $claimedByOtherRunPostPlatform) {
return count($mail->postPlatformIds) === 1
&& in_array($stillClaimableByThisRunPostPlatform->id, $mail->postPlatformIds, true)
&& ! in_array($claimedByOtherRunPostPlatform->id, $mail->postPlatformIds, true);
});
});
test('does not crash when the account is deleted between being loaded and the TokenExpiredException being handled', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturnUsing(function () use ($account) {
// Simulate the user disconnecting (hard-deleting) the account in the
// brief window between this job loading it and handling the
// exception thrown here.
SocialAccount::where('id', $account->id)->delete();
throw new TokenExpiredException('Threads access token is invalid or expired');
});
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('a deleted account does not abort the run for other accounts in the same workspace', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$deletedAccount = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$deletedPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(15),
]);
$deletedPostPlatform = PostPlatform::factory()->create([
'post_id' => $deletedPost->id,
'social_account_id' => $deletedAccount->id,
'platform' => $deletedAccount->platform,
'status' => PostPlatformStatus::Pending,
]);
$survivingAccount = SocialAccount::factory()->facebook()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$survivingPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$survivingPostPlatform = PostPlatform::factory()->create([
'post_id' => $survivingPost->id,
'social_account_id' => $survivingAccount->id,
'platform' => $survivingAccount->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->once()
->with(Mockery::on(fn ($account) => $account->id === $deletedAccount->id))
->andReturnUsing(function () use ($deletedAccount) {
SocialAccount::where('id', $deletedAccount->id)->delete();
throw new TokenExpiredException('Threads access token is invalid or expired');
});
$verifier->shouldReceive('verify')
->once()
->with(Mockery::on(fn ($account) => $account->id === $survivingAccount->id))
->andThrow(new TokenExpiredException('Facebook access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($deletedPostPlatform->fresh()->connection_warning_sent_at)->toBeNull();
expect($survivingPostPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
expect($survivingAccount->fresh()->status)->toBe(SocialAccountStatus::TokenExpired);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($survivingPostPlatform) {
return count($mail->postPlatformIds) === 1
&& in_array($survivingPostPlatform->id, $mail->postPlatformIds, true);
});
});
test('a post deleted between the main query and the eager-loaded post relation resolving does not crash the run', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$doomedPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(15),
]);
$doomedPostPlatform = PostPlatform::factory()->create([
'post_id' => $doomedPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$survivingPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$survivingPostPlatform = PostPlatform::factory()->create([
'post_id' => $survivingPost->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
// atRiskPostPlatforms() runs the post_platforms select, then a separate
// eager-load query for the post relation. Deleting the post right after
// the first query — but before the second — reproduces the race: this
// job's in-memory PostPlatform row still exists, but its post relation
// resolves to null once the eager load runs.
// str_starts_with (not str_contains) deliberately excludes the
// recentlyWarnedAbout()/recentlyDisconnected() exists() subqueries —
// Laravel compiles ->exists() as "select exists(select * from
// \"post_platforms\" where ...)", which contains but doesn't start with
// this prefix, so those never trip the listener.
$listener = function ($query) use ($doomedPost) {
if (str_starts_with($query->sql, 'select * from "post_platforms"')) {
Post::where('id', $doomedPost->id)->delete();
}
};
DB::listen($listener);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
// post_platforms.post_id cascades on delete, so the row itself is gone —
// nothing left to warn about for it.
expect($doomedPostPlatform->fresh())->toBeNull();
expect($survivingPostPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
expect($account->fresh()->status)->toBe(SocialAccountStatus::TokenExpired);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($survivingPostPlatform) {
return count($mail->postPlatformIds) === 1
&& in_array($survivingPostPlatform->id, $mail->postPlatformIds, true);
});
});
test('does not verify or warn about a post_platform on a paused account', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
'is_active' => false,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('still verifies and warns about an active account when another account in the same workspace is paused', function () {
config()->set('trypost.allow_multiple_social_accounts', true);
Mail::fake();
$workspace = Workspace::factory()->create();
$pausedAccount = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
'is_active' => false,
]);
$pausedPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$pausedPostPlatform = PostPlatform::factory()->create([
'post_id' => $pausedPost->id,
'social_account_id' => $pausedAccount->id,
'platform' => $pausedAccount->platform,
'status' => PostPlatformStatus::Pending,
]);
$activeAccount = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$activePost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(25),
]);
$activePostPlatform = PostPlatform::factory()->create([
'post_id' => $activePost->id,
'social_account_id' => $activeAccount->id,
'platform' => $activeAccount->platform,
'status' => PostPlatformStatus::Pending,
]);
// andReturnUsing (rather than a single ->with()-constrained expectation)
// deliberately avoids a Mockery expectation-mismatch exception: the job's
// own catch (Exception $e) around verify() would silently swallow that,
// masking a missing is_active guard instead of failing the test.
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->andReturnUsing(function (SocialAccount $account) use ($activeAccount) {
if ($account->id !== $activeAccount->id) {
// Only reachable if the is_active guard fails to exclude the paused account.
return true;
}
throw new TokenExpiredException('Threads access token is invalid or expired');
});
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($pausedAccount->fresh()->last_verified_at)->toBeNull();
expect($pausedPostPlatform->fresh()->connection_warning_sent_at)->toBeNull();
expect($activePostPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($activePostPlatform, $pausedPostPlatform) {
return count($mail->postPlatformIds) === 1
&& in_array($activePostPlatform->id, $mail->postPlatformIds, true)
&& ! in_array($pausedPostPlatform->id, $mail->postPlatformIds, true);
});
});
test('does not verify or warn about an account paused mid-run, after atRiskPostPlatforms() already selected its post_platform', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
// atRiskPostPlatforms()'s is_active guard is query-time only — pause the
// account right after its main select runs (already passed the guard)
// but before the per-account loop reaches it, reproducing the race the
// fresh() re-check at the top of each account's iteration exists to close.
$listener = function ($query) use ($account) {
if (str_starts_with($query->sql, 'select * from "post_platforms"')) {
$account->update(['is_active' => false]);
}
};
DB::listen($listener);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('does not warn about an already token_expired account paused mid-run, after atRiskPostPlatforms() already selected its post_platform', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::TokenExpired,
'error_message' => 'Threads access token is invalid or expired',
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
// The "already known broken" branch (no verify() call, straight to
// $atRisk) must respect the same mid-run pause race as the verify()
// path — an account paused between the main select and the loop
// reaching it shouldn't get warned about a connection its owner
// deliberately paused, even though it's already broken.
$listener = function ($query) use ($account) {
if (str_starts_with($query->sql, 'select * from "post_platforms"')) {
$account->update(['is_active' => false]);
}
};
DB::listen($listener);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('does not crash or warn when the account is hard-deleted mid-run, after atRiskPostPlatforms() already selected its post_platform', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
// Same race as the two tests above, but the account is hard-deleted
// instead of paused — social_account_id is nullOnDelete(), so the
// post_platform row survives with a dangling reference. The re-fetch
// guard must handle "row is gone" the same way it handles "row is
// paused": SocialAccount::active()->find() returns null either way.
// Deleting after the socialAccount eager-load query (rather than the
// main post_platforms select, like the two tests above) is deliberate:
// deleting that early would instead hit the pre-existing, unrelated
// "$account is null straight out of the eager load" branch — this
// needs the account to still resolve as non-null going into the loop,
// then disappear before the guard's own re-fetch runs.
$listener = function ($query) use ($account) {
if (str_starts_with($query->sql, 'select * from "social_accounts"')) {
$account->delete();
}
};
DB::listen($listener);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('does not warn and does not mark connection_warning_sent_at on PlatformUnavailableException', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new PlatformUnavailableException('Threads API returned 503'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
expect($account->fresh()->status)->toBe(SocialAccountStatus::Connected);
Mail::assertNothingQueued();
});
test('does nothing when the account verifies successfully', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
expect($account->fresh()->status)->toBe(SocialAccountStatus::Connected);
Mail::assertNothingQueued();
});
test('an unexpected exception verifying one account does not abort the run for other accounts', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$brokenAccount = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$brokenPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$brokenPostPlatform = PostPlatform::factory()->create([
'post_id' => $brokenPost->id,
'social_account_id' => $brokenAccount->id,
'platform' => $brokenAccount->platform,
'status' => PostPlatformStatus::Pending,
]);
$expiredAccount = SocialAccount::factory()->facebook()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$expiredPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$expiredPostPlatform = PostPlatform::factory()->create([
'post_id' => $expiredPost->id,
'social_account_id' => $expiredAccount->id,
'platform' => $expiredAccount->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->once()
->with(Mockery::on(fn ($account) => $account->id === $brokenAccount->id))
->andThrow(new ConnectionException('Could not resolve host'));
$verifier->shouldReceive('verify')
->once()
->with(Mockery::on(fn ($account) => $account->id === $expiredAccount->id))
->andThrow(new TokenExpiredException('Facebook access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($brokenPostPlatform->fresh()->connection_warning_sent_at)->toBeNull();
expect($brokenAccount->fresh()->status)->toBe(SocialAccountStatus::Connected);
expect($expiredPostPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
expect($expiredAccount->fresh()->status)->toBe(SocialAccountStatus::TokenExpired);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($expiredPostPlatform) {
return count($mail->postPlatformIds) === 1
&& in_array($expiredPostPlatform->id, $mail->postPlatformIds, true);
});
});
test('ignores disabled platforms even inside the risk window', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
PostPlatform::factory()->disabled()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
Mail::assertNothingQueued();
});
test('does not leak another workspace\'s at-risk posts into this workspace\'s notification', function () {
Mail::fake();
$workspaceA = Workspace::factory()->create();
$accountA = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspaceA->id,
'status' => SocialAccountStatus::Connected,
]);
$postA = Post::factory()->scheduled()->create([
'workspace_id' => $workspaceA->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatformA = PostPlatform::factory()->create([
'post_id' => $postA->id,
'social_account_id' => $accountA->id,
'platform' => $accountA->platform,
'status' => PostPlatformStatus::Pending,
]);
$workspaceB = Workspace::factory()->create();
$accountB = SocialAccount::factory()->facebook()->create([
'workspace_id' => $workspaceB->id,
'status' => SocialAccountStatus::Connected,
]);
$postB = Post::factory()->scheduled()->create([
'workspace_id' => $workspaceB->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatformB = PostPlatform::factory()->create([
'post_id' => $postB->id,
'social_account_id' => $accountB->id,
'platform' => $accountB->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')
->once()
->with(Mockery::on(fn ($account) => $account->id === $accountA->id))
->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
$verifier->shouldNotReceive('verify')
->with(Mockery::on(fn ($account) => $account->id === $accountB->id));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspaceA->id);
expect($postPlatformA->fresh()->connection_warning_sent_at)->not->toBeNull();
expect($postPlatformB->fresh()->connection_warning_sent_at)->toBeNull();
expect($accountB->fresh()->status)->toBe(SocialAccountStatus::Connected);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($workspaceA, $postPlatformA, $postPlatformB) {
return $mail->workspace->id === $workspaceA->id
&& in_array($postPlatformA->id, $mail->postPlatformIds, true)
&& ! in_array($postPlatformB->id, $mail->postPlatformIds, true);
});
Mail::assertQueuedCount(1);
});
test('re-evaluates a post_platform warned more than a day ago instead of skipping it forever', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
'connection_warning_sent_at' => now()->subDay()->subMinute(),
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull()
->and($postPlatform->fresh()->connection_warning_sent_at->isAfter(now()->subMinute()))->toBeTrue();
Mail::assertQueued(PostAtRisk::class);
});
test('still skips a post_platform warned less than a day ago', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$warnedAt = now()->subHours(2);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
'connection_warning_sent_at' => $warnedAt,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldNotReceive('verify');
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at->format('Y-m-d H:i:s'))
->toBe($warnedAt->format('Y-m-d H:i:s'));
Mail::assertNothingQueued();
});
test('does not re-warn a re-armed post_platform when the account has since been reconnected', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$warnedAt = now()->subDay()->subMinute();
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
'connection_warning_sent_at' => $warnedAt,
]);
// Old warning is stale enough to re-arm the row, but this time the
// account verifies fine — the user reconnected in the meantime.
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
// The row was re-evaluated (not skipped — verify() was called), but
// since it came back healthy, nothing about it changes: no new
// warning, no notification, no touch to the account's status.
expect($postPlatform->fresh()->connection_warning_sent_at->format('Y-m-d H:i:s'))
->toBe($warnedAt->format('Y-m-d H:i:s'));
expect($account->fresh()->status)->toBe(SocialAccountStatus::Connected);
Mail::assertNothingQueued();
});
test('does not crash the run on a post_platform with a null social_account_id', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$orphanedPost = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
PostPlatform::factory()->create([
'post_id' => $orphanedPost->id,
'social_account_id' => null,
'status' => PostPlatformStatus::Pending,
]);
$account = SocialAccount::factory()->facebook()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(20),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Facebook access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->not->toBeNull();
expect($account->fresh()->status)->toBe(SocialAccountStatus::TokenExpired);
Mail::assertQueued(PostAtRisk::class, function ($mail) use ($postPlatform) {
return count($mail->postPlatformIds) === 1
&& in_array($postPlatform->id, $mail->postPlatformIds, true);
});
});
test('leaves posts unwarned and does not send an email when the workspace has no owner', function () {
Mail::fake();
$workspace = Workspace::factory()->create(['user_id' => null]);
$account = SocialAccount::factory()->threads()->create([
'workspace_id' => $workspace->id,
'status' => SocialAccountStatus::Connected,
]);
$post = Post::factory()->scheduled()->create([
'workspace_id' => $workspace->id,
'scheduled_at' => now()->addMinutes(30),
]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'platform' => $account->platform,
'status' => PostPlatformStatus::Pending,
]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new TokenExpiredException('Threads access token is invalid or expired'));
app()->instance(ConnectionVerifier::class, $verifier);
VerifyUpcomingPostConnections::dispatchSync($workspace->id);
expect($postPlatform->fresh()->connection_warning_sent_at)->toBeNull();
Mail::assertNothingQueued();
});
test('job is unique per workspace with a window covering its timeout', function () {
$job = new VerifyUpcomingPostConnections('workspace-uuid');
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
->and($job->uniqueId())->toBe('workspace-uuid')
->and($job->uniqueFor)->toBeGreaterThanOrEqual($job->timeout);
});