trypost/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php
Paulo Castellano a147c7414b
feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256)
* chore: gitignore .superpowers/ scratch workspace

Holds per-plan subagent-driven-development artifacts (ledger, briefs,
review packages) — scratch state, not part of the shipped codebase.

* feat: add connection_warning_sent_at to post_platforms

* feat: add PostAtRisk notification type and translations

* fix: add user_id to NotificationPreferenceFactory definition for ->create() support

* feat: add PostAtRisk mailable and email template

* feat: add VerifyUpcomingPostConnections job

* fix: guard VerifyUpcomingPostConnections against transient errors and cross-workspace leaks

- Add a generic \Exception catch around ConnectionVerifier::verify() so a
  transient error (e.g. ConnectionException) on one account can't abort
  processing of every other at-risk account in the workspace run.
- Eager-load socialAccount.workspace so markAsTokenExpired's observer chain
  never lazy-loads it — this only ever manifested once 2+ distinct accounts
  were hydrated in a single run (Eloquent only sets preventsLazyLoading on
  batch hydration of >1 row), which is exactly the multi-account scenario
  this job exists to handle.
- Add covering tests: enabled=false posts are excluded, one workspace's
  at-risk posts never leak into another workspace's notification, and an
  unexpected exception on one account doesn't stop the rest of the run.

* feat: add social:check-upcoming-connections command and schedule it

* fix: add composite index for the 15-minute upcoming-post connection query

post_platforms(status, connection_warning_sent_at) supports the filter both
VerifyUpcomingPostConnections and social:check-upcoming-connections run every
15 minutes; without it, every run does a full table scan that only grows as
posts accumulate.

* fix: localize the PostAtRisk email's per-account line and label times as UTC

The postsLabel line was the only hardcoded-English content in an otherwise
fully-translated email, and it showed scheduled_at times with no timezone
indicator even though the app stores everything in UTC. Add
mail.post_at_risk.posts_label (pluralized, one entry per locale, mirroring
each locale's existing post_at_risk.subject plural-boundary syntax) and use
trans_choice() to build the line, with a literal " UTC" suffix left
untranslated in every locale like a unit abbreviation.

Also document why content() reassigns the public $atRiskGroups property
instead of using a local variable (Mailable::buildViewData() overwrites
with() data with same-named public properties).

* fix: time-box the warning dedup and guard against orphaned/ownerless rows

- Re-arm connection_warning_sent_at after a day instead of permanently
  suppressing it, so a post rescheduled back into the risk window after a
  stale warning is re-evaluated instead of silently skipped forever.
- Exclude post_platforms with a null social_account_id from the at-risk
  query. With tries=1, dereferencing a null socialAccount relation would
  abort the whole workspace run, including already-detected broken accounts.
- Resolve and check the workspace owner before stamping
  connection_warning_sent_at, so an ownerless workspace's posts are left
  un-warned (available to be picked up once it gets an owner) instead of
  being marked "warned" with no notification ever sent.

Applied the same dedup time-boxing and null-account guard to the
social:check-upcoming-connections dispatch query for consistency.

* fix: PostAtRisk email is always English — drop the locale translation layer

config('app.locale')/App::setLocale() is only ever set by the SetLocale
web middleware, which reads a cookie off the incoming HTTP request. Every
Mailable in this branch is built inside a queued job (SendNotification),
which runs outside the HTTP request lifecycle entirely — no middleware,
no cookie, nothing sets the locale there. So content() always resolved
'app.locale' to the static APP_LOCALE default ('en') regardless of the
recipient's actual preference: the 16-locale mail.post_at_risk.* keys
were dead weight from the start, matching an existing (pre-existing,
out of scope here) gap in the sibling WorkspaceConnectionsDisconnected/
AccountDisconnected mailables.

Replaces the trans_choice()/__() calls with plain English strings built
directly in PostAtRisk, and removes the now-unused mail.post_at_risk.*
block from all 16 locale files. Also strengthens the mailable test to
assert the full "N post(s) scheduled: ... UTC" string, not just a
fragment of it.

* refactor: consolidate the two post_platforms migrations from this branch into one

connection_warning_sent_at and its supporting index were added in two
separate migrations (the column in the original task, the index during
final review). Both are still unmerged/unshipped on this branch, so
folding the index into the same migration that adds the column is safe
and keeps the schema change to post_platforms as one unit instead of two.

Verified with a full rollback + re-migrate cycle that the consolidated
up()/down() is self-consistent.

* refactor: add PostPlatform::scopeEnabled(), replace ->where('enabled', true) everywhere

The raw where('enabled', true) clause was duplicated across 17 call sites
in 12 files (13 including the 2 this branch added), all expressing the
same rule PublishPost enforces at publish time: only enabled platforms
are eligible. Added a scopeEnabled() to PostPlatform and swapped every
query-builder call site to ->enabled().

Three call sites are intentionally left untouched: they filter an
already-loaded relation Collection (->postPlatforms->where(...), no
parens), which is Collection::where(), not a query scope — a query scope
can't apply to an in-memory collection.

No inverse (enabled = false) query pattern exists anywhere in the
codebase — 'enabled' => false only ever appears as a write when a post
is disabled/synced, never as a read filter — so no scopeDisabled() was
added; nothing would call it.

* test: cover re-armed post_platform where the account was reconnected

The re-arm dedup fix (connection_warning_sent_at older than a day is
treated as null) only had coverage for "still broken, warns again" and
"too recent, stays skipped". Missing: the row gets re-evaluated (verify()
is called, not skipped) but comes back healthy because the user
reconnected in the meantime — nothing should change (no new warning, no
notification, marker stays at its old value).

* fix: dispatch-level uniqueness, index the enabled filter, close markAsTokenExpired race

From a deep review pass on the whole branch:

- VerifyUpcomingPostConnections now implements ShouldBeUnique (keyed on
  workspaceId, 300s window). withoutOverlapping() on the schedule only
  serializes the fast-dispatching command; a queue backlog could still let
  two jobs for the same workspace run concurrently, both mailing the owner
  for the same at-risk posts.
- The composite index now covers enabled too (status, enabled,
  connection_warning_sent_at) — every query that uses it filters on all
  three, so the index previously required a heap fetch per row just to
  check enabled.
- markAsTokenExpired() silently no-ops if it loses the account's status
  lock to a concurrent process (a publish attempt, the daily check). The
  job used to push the account into the at-risk notification regardless
  of whether the update actually landed. It now re-checks the account's
  status after the call and only warns if the transition is confirmed —
  a lost race just defers the account to the next run instead of sending
  a misleading "reconnect" email for an account whose status didn't change.

Also includes an unrelated stray Pint fix (inline \Throwable -> imported)
in SendNotification.php that had been sitting uncommitted.

* refactor: centralize account handle/display name, expose to frontend, close review findings

Adds SocialAccount::handle()/accountDisplayName() plus appended
display_label/handle_label JSON fields, replacing duplicated
username/display_name fallback logic scattered across platform
previews, NetworkConnectGrid, PreviewTab, Calendar, and the post
editor pages.

Also closes the remaining findings from the final review on this
branch: escapes the workspace name in PostAtRisk's intro (and drops
the now-unnecessary raw-HTML rendering), fixes the tautological
"dispatches once per workspace" test, adds plural/subject test
coverage for PostAtRisk, raises VerifyUpcomingPostConnections'
uniqueFor to cover the full schedule cadence, and updates a stale
docblock.

* test: cover draft-post exclusion, account status after PlatformUnavailableException

Adds the two coverage gaps left open by the last review: a post still
in Draft status inside the 1-hour window must not trigger a check or
warning, and a PlatformUnavailableException must leave the account
status untouched. Also drops the dedicated PostAtRisk XSS test — the
intro is now plain Blade-escaped text, so the coverage is redundant
with the framework's own escaping.

* fix: close final review findings — i18n notification, empty-string fallback, missed refactor sites

- Localize the in-app "post at risk" notification title in all 16
  locales via trans_choice (the email stays English, unchanged)
- Use ?: instead of ?? in handle()/accountDisplayName()/handleLabel()
  so an empty-string username/display_name still falls back, matching
  the old Vue || behavior
- Migrate the 3 frontend sites the earlier sweep missed (Index.vue,
  SocialAccountsGrid.vue, ScheduleTab.vue) to display_label/handle_label
- Fix avatar-initial fallback in the platform preview components to use
  display_label instead of raw display_name
- Correct handle_label's TS type to string | null across 10 files to
  match the accessor's actual return type
- Add test coverage for the command-level "already warned" dedup path
  and the in-app Notification row created alongside PostAtRisk's email

* fix: notification storm, duplicate-email race, and queue payload bloat in upcoming-post checks

Three correctness issues found by review, fixed after discussion:

- An already-broken account could get a fresh PostAtRisk email every
  15 minutes for as long as it stayed broken, if new posts kept
  entering the 1-hour risk window. Gated with a per-account 60-minute
  renotify cooldown.
- Two concurrent jobs (RefreshExpiringTokens and this one) could each
  discover the same dead token and send their own email for it
  (AccountDisconnected + PostAtRisk) within the same tick. Gated with
  a 5-minute grace period, applied only when another process already
  transitioned the account before we got to it — not when we're the
  one making the transition.
- PostAtRisk carried full SocialAccount/PostPlatform/Post model
  graphs on the queue payload, since SerializesModels can't reduce
  models nested inside a plain array/Collection to lightweight
  identifiers. It now carries only post_platform IDs and rehydrates
  at send time, with envelope()/content() sharing one memoized query
  so their counts can't disagree.

Also replaces the account-health cache with a persisted
SocialAccount.last_verified_at column, and narrows the actual
platform API calls to only fire once a post's nearest scheduled_at
is within 30 minutes — enough lead time to reconnect, without
spending API budget checking a full hour out.

* fix: replace dead unsubscribe link with notification preferences, finish display_label sweep

The shared mail footer's unsubscribe link was permanently dead code
(unsubscribe_url was never passed by any Mailable). Replaced it with
a fixed "Manage notifications" link to the real settings page,
via route('app.notifications.preferences').

Also closes out the remaining sites still computing the
username/display_name fallback locally instead of reading the
backend-computed display_label: 8 more Vue components (platform
previews, per-platform post-editor settings, the AI post wizard, the
automation Generate node config, and the analytics account selector)
plus two PHP call sites (PostPlatform::getDisplayNameAttribute(),
already fixed on main before this branch, and the template image
generator's rendered footer text).

* fix: only show "Manage notifications" on preference-driven emails

The link doesn't make sense on transactional emails that always send
regardless of notification preferences (password reset, email
verification) or that go to recipients who may not even have an
account yet (workspace invite) — and the settings page it points to
requires login, which is actively broken for the first two.

Split the shared footer into two Maizzle components: footer.html
(plain) for the 3 transactional templates, footer-authenticated.html
(adds the link) for the 6 that go through SendNotification and
respect the recipient's notification preferences.

* fix: lock PostAtRisk's subject to the dispatch-time count, expose handle_label from analytics

PostAtRisk's subject/previewText were recomputed from a fresh DB
query at send time, while the in-app notification's title (built in
VerifyUpcomingPostConnections::notifyOwner()) used the count observed
at dispatch time. If a post_platform row disappeared in between, the
two could disagree. The count is now passed into the mailable
explicitly and reused for both — the body's account/post details
still rehydrate fresh from the DB, preserving the anti-staleness fix
from earlier in this branch.

Also adds handle_label to AnalyticsController's account payload,
matching every other endpoint that serializes a SocialAccount.

* fix: don't abort the whole workspace run if an account is deleted mid-verify

An exception thrown inside a catch block isn't routed to a sibling
catch, so $account->refresh() throwing ModelNotFoundException (the
user disconnected/deleted the account in the brief window between
this job loading it and handling the TokenExpiredException) escaped
handle() entirely. With tries = 1, that killed the run for every
other account in the same workspace, not just the deleted one.

Also fixes an inconsistent placeholder in PlatformPreview.vue
(handle_label: null instead of '', matching display_label).

* fix: guard against deleted accounts, guarantee a non-empty account name

Closes the last 4 findings from the sixth review round:
- VerifyUpcomingPostConnections now skips a group whose account
  resolved to null (deleted between the main query and its eager-loaded
  relation), instead of an unguarded property access aborting the
  whole workspace's run
- the same job's nested exception handler now covers any \Exception
  from markAsTokenExpired() (lock/DB failures), not just
  ModelNotFoundException
- PostAtRisk drops a rehydrated group whose account no longer exists
  instead of crashing the render (verified: fails without the fix,
  passes with it)
- AnalyticsController's handle_label field is now actually consumed by
  AnalyticsAccountSelector.vue instead of being unused payload

Also closes a real gap: every connector requests enough OAuth scope to
populate at least one of username/display_name (confirmed for TikTok,
whose account.py comment implied otherwise but whose connect() scopes
always include user.info.profile), so accountDisplayName()/handle()/
displayLabel/handleLabel now return a guaranteed non-empty string
(falling back to the platform label only as a last resort) instead of
being nullable. This removes the now-pointless @if guards around
accountDisplayName() in the account-disconnected and post-at-risk
email templates, and lets ~30 frontend files drop the `| null` from
display_label/handle_label and the ?? undefined fallbacks that only
existed to satisfy that type.

* fix: drop the now-pointless ?? '' fallback on display_label in TemplateImageGenerator

display_label is a guaranteed non-empty string (see 950558b4).

* fix: correct social_account's TS type to nullable in Index.vue and Calendar.vue

Both declared social_account as required while their own templates
used optional chaining (pp.social_account?.display_label) — the type
was lying. social_account_id is nullable and the account can be
deleted (FK is nullOnDelete), so the field genuinely can be null.

Swept every other social_account/socialAccount field in resources/js
for the same mismatch; all others already declared it correctly.

* Centralize avatar-initial extraction via getInitials()

Replace hand-rolled .charAt(0)/.charAt(0).toUpperCase() avatar-initial
logic across social account previews, the accounts grid, the analytics
account selector, and the mention picker with the existing
useInitials() composable already used by Avatar.vue.

* Drop pointless display_label fallbacks now that it's always populated

display_label is guaranteed non-empty (falls back to the platform
label server-side), so || 'Channel' / || 'TryPost' / ?? platform were
unreachable.

* Fix cold-review findings: dead handle_label guard, slug leak, wrong post count

- AnalyticsAccountSelector: the "@handle" line's guard/value must read the
  raw username (nullable — Facebook Pages and Telegram channels legitimately
  have none), not handle_label, which always resolves to something and made
  the guard permanently true. Drop the now-orphaned handle_label field from
  the analytics payload/type since nothing else in analytics used it.
- PlatformPreview: the no-account-selected fallback now uses
  getPlatformLabel() instead of the raw platform slug, matching the
  backend's own last-resort label fallback.
- VerifyUpcomingPostConnections: count distinct posts (post_id), not
  post_platform rows, so one post spanning multiple broken accounts doesn't
  inflate the at-risk count in the email subject and notification title.

* Fix cold-review round 2: silent Telegram/Discord false negative, flaky email ordering, dead display_name

- VerifyUpcomingPostConnections: ConnectionVerifier::verify() reports a
  dead Telegram/Discord connection by returning false rather than
  throwing. The job discarded that return value, so a bot removed from
  a channel/guild was stamped last_verified_at and silently trusted
  healthy for the next 40 minutes — no warning, post just fails at
  publish time. Route a false return through the same
  TokenExpiredException handling used by every other platform.
- PostAtRisk: atRiskGroups() had no ORDER BY, so the per-account
  "N posts scheduled: H:i, H:i UTC" line rendered in arbitrary
  (physical row) order. Sort by scheduled_at before formatting.
- Drop the orphaned display_name field from the analytics payload/type
  (superseded by display_label; nothing in resources/js/components/
  analytics or pages/analytics read it).

* Add social icons and copyright to email footers

Icons match the trypost-site footer (outline @tabler/icons style,
converted to PNG since email clients — notably Outlook desktop — don't
render inline SVG). Reordered footer content: tagline, manage-notifications
link, icons as the closing element, copyright line last.

* Standardize connection-verify error classification across all 13 platforms

Every platform now follows one contract: verify() returns true on a
healthy connection, throws TokenExpiredException only on a confirmed
dead connection, and PlatformUnavailableException on anything else
(rate limit, 5xx, unrecognized). Previously most platforms silently
returned false on anything but a 401, so callers (all of which only
react via try/catch) could never distinguish "definitely dead" from
"transient" — and Telegram/Discord never threw at all.

Each platform's "is this confirmed dead" check now lives next to its
existing publish-time error classifier (App\Exceptions\Social\*PublishException)
instead of being re-typed inline in ConnectionVerifier, closing real,
already-drifted gaps between the two paths:

- TikTok and Mastodon both had a bare "status === 401/403" check shared
  between publish and verify, but TikTok's scope_not_authorized and
  Mastodon's write-scope 403 use the same status for a non-fatal scope
  gap, not a dead token — verify's lower-privilege endpoint keeps its
  own stricter check on top instead.
- Telegram/Discord authenticate with one bot token shared across every
  connected account; a 401 means that shared token is misconfigured
  (an operator problem), never that one specific account is broken —
  excluded from both platforms' confirmed-dead checks accordingly.
- Facebook/InstagramFacebook/Mastodon/Telegram/Discord have no
  per-account refresh flow at all, so a confirmed rejection now skips
  the pointless refresh-and-retry (Platform::hasTokenRefreshFlow()).

Also fixes two bugs found while hardening VerifyUpcomingPostConnections:
a post hard-deleted mid-run could crash the whole job for every other
account in the batch (now filtered per group), and two overlapping runs
of the same job could send duplicate PostAtRisk warnings (now a
conditional claim on connection_warning_sent_at).

* Skip paused accounts in upcoming-post connection checks, close claim race

A paused (is_active=false) social account already fails at publish time
before any platform API call, so it shouldn't trigger a proactive
connection check or "reconnect" warning. Guard added at dispatch time
(CheckUpcomingPostConnections) and re-checked fresh mid-run inside
VerifyUpcomingPostConnections's per-account loop, since the job can take
real wall-clock time working through a workspace and an account can be
paused or deleted after the query-time guard already ran.

Also wraps the connection_warning_sent_at claim in a SELECT ... FOR UPDATE
transaction (ordered by id, 3 retries) to close a race between two
overlapping runs of the same job double-claiming and double-emailing about
the same post_platform.

* Clarify "commit" wording in claim-transaction comment

Reads ambiguously as a git commit on a PR diff; it means the DB
transaction commit.
2026-08-09 11:10:39 -03:00

1583 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 () {
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);
});