trypost/tests/Feature/Commands/RefreshExpiringTokensTest.php

166 lines
6.2 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Queue;
test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () {
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 10:28:14 +00:00
config()->set('trypost.allow_multiple_social_accounts', true);
Queue::fake();
$workspace = Workspace::factory()->create();
// Rotating platform expiring in 15 minutes — inside the 30-minute window.
$rotatingSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::LinkedIn,
'status' => Status::Connected,
'token_expires_at' => now()->addMinutes(15),
]);
// Rotating platform expiring in 1 hour — OUTSIDE the 30-minute window.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// Extension platform expiring in 1 hour — inside the wide 24-hour window.
// (On the old shared 30-minute window this lapsed under queue backlog.)
$extensionSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// Extension platform expiring in 12 hours — still inside the 24-hour window.
$extensionLater = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Threads,
'status' => Status::Connected,
'token_expires_at' => now()->addHours(12),
]);
// Extension platform expiring in 2 days — OUTSIDE the 24-hour window.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addDays(2),
]);
// Rotating platform already expired — last-chance attempt before the
// refresh_token also dies at the provider.
$rotatingExpired = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::TikTok,
'status' => Status::Connected,
'token_expires_at' => now()->subHour(),
]);
// Disconnected — never refreshed.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Pinterest,
'status' => Status::Disconnected,
'token_expires_at' => now()->addHour(),
]);
// Already token expired — daily verify handles these.
fix(social): proactive token refresh actually refreshes (not just verifies) Three orthogonal fixes that together close the gap where social tokens were silently aging out without ever being refreshed, then dying at the provider when the refresh_token also got revoked. The original failure mode: a user's X token expired because the hourly proactive-refresh cron's smart `verify()` skip-logic kept saying 'token still works, no need to refresh', and once the token actually expired, the cron's WHERE clause excluded it from future runs. By the time anyone noticed, the refresh_token at X was also gone. (C) ConnectionVerifier: rename private `refreshTokenIfNeeded` → public `refreshToken`. Callers that want the smart 'try access_token first' behavior keep using `verify()`. Callers that want a proactive refresh (the cron) call `refreshToken` directly. (B) RefreshExpiringTokens command: drop the `where('token_expires_at', '>', now())` filter. Already-expired tokens now get a last-chance refresh attempt before the refresh_token also dies at the provider. Status filter (`Connected`) still excludes accounts already marked TokenExpired. (D) RefreshSocialToken job: switch from `verify()` to `refreshToken()`, and on `TokenExpiredException` call `markAsTokenExpired` so the user is notified immediately. The lock + transition detection in markAsTokenExpired prevents notification spam if subsequent cron passes also fail. Tests: - 3 new tests for RefreshSocialToken (calls refreshToken not verify, marks TokenExpired on TokenExpiredException, logs warning on other errors) - Updated RefreshExpiringTokens test to assert already-expired tokens are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::YouTube,
fix(social): proactive token refresh actually refreshes (not just verifies) Three orthogonal fixes that together close the gap where social tokens were silently aging out without ever being refreshed, then dying at the provider when the refresh_token also got revoked. The original failure mode: a user's X token expired because the hourly proactive-refresh cron's smart `verify()` skip-logic kept saying 'token still works, no need to refresh', and once the token actually expired, the cron's WHERE clause excluded it from future runs. By the time anyone noticed, the refresh_token at X was also gone. (C) ConnectionVerifier: rename private `refreshTokenIfNeeded` → public `refreshToken`. Callers that want the smart 'try access_token first' behavior keep using `verify()`. Callers that want a proactive refresh (the cron) call `refreshToken` directly. (B) RefreshExpiringTokens command: drop the `where('token_expires_at', '>', now())` filter. Already-expired tokens now get a last-chance refresh attempt before the refresh_token also dies at the provider. Status filter (`Connected`) still excludes accounts already marked TokenExpired. (D) RefreshSocialToken job: switch from `verify()` to `refreshToken()`, and on `TokenExpiredException` call `markAsTokenExpired` so the user is notified immediately. The lock + transition detection in markAsTokenExpired prevents notification spam if subsequent cron passes also fail. Tests: - 3 new tests for RefreshSocialToken (calls refreshToken not verify, marks TokenExpired on TokenExpiredException, logs warning on other errors) - Updated RefreshExpiringTokens test to assert already-expired tokens are now dispatched (was previously asserted as 'should NOT')
2026-05-12 22:36:35 +00:00
'status' => Status::TokenExpired,
'token_expires_at' => now()->subHour(),
]);
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 4);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionLater->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingExpired->id);
});
test('extension-model platforms get a wider refresh window than rotating platforms', function () {
Queue::fake();
$workspace = Workspace::factory()->create();
// Same expiry (1 hour out) for both — only the extension-model account
// should be dispatched, because it can't be refreshed once expired.
$extension = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
$rotating = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 1);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extension->id);
Queue::assertNotPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotating->id);
});
test('it dispatches nothing when no tokens are expiring', function () {
Queue::fake();
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertNothingPushed();
});
Remove the X API reads that buy nothing (#299) * Stop paying X for token checks the refresh already proves RefreshSocialToken verified rotating-refresh platforms instead of refreshing them. On a still-valid token verify() only called the platform's verify endpoint and left token_expires_at untouched, so the account stayed inside RefreshExpiringTokens' 30-minute window and was re-read every 15 minutes until the token actually died. On X that endpoint is GET /2/users/me, billed as a "User: Read" ($0.010 per resource under X's pay-per-usage pricing). Simulating 24h of the scheduler against one connected X account: 36 billed reads per day, 24 of which renewed nothing, plus 180 minutes per day sitting on an expired token between expiry and the next tick. Refresh the token outright instead. A provider that hands back a fresh token has already confirmed the credential — it rejects a revoked one with a 4xx, which TokenRefreshClient maps to TokenExpiredException — so the verify call adds cost and nothing else. Record the confirmation in last_verified_at, and let the daily sweep trust it for 12 hours the way VerifyUpcomingPostConnections already does, so it stops re-reading accounts a refresh just proved valid. Same simulation after the change: 0 billed reads, 0 minutes expired. Two existing tests asserted the old policy (verify-first, refresh_token left unrotated) and now assert the new one. The rotation test still guards what made that policy attractive: a proactive rotation must not trip a false-positive disconnect. * Don't disconnect an account whose access token still works Refreshing instead of verifying removed a safety net the old verify-first path had: if the refresh is rejected, the account was marked TokenExpired outright. But a rejected refresh does not mean the connection is dead. X and LinkedIn single-use their refresh_token, so a token a concurrent refresh already consumed comes back 4xx while the current access_token keeps working. An account with no refresh_token at all fails even earlier, without a single call being made. Verified against main: both cases stayed Connected before, and became TokenExpired after. PublishToSocialPlatform hard-fails every post for a TokenExpired account, so this killed posts the access_token would have published, up to 30 minutes before the token was actually due to expire — and emailed the owner a disconnect notice for it. Fall back to verifying the access token before disconnecting. This is the only path in the job that reaches the billed verify endpoint, and only after a refresh has already been rejected, so the healthy path stays at zero reads (re-confirmed: 0 reads and 0 expired minutes across a simulated 24h). A failure that can't be attributed to the token — platform down, network blip — leaves the account alone instead of disconnecting it on noise. Also covers two gaps found while reviewing: a lock-skipped refresh must not record a verification it never performed, and a platform with nothing to refresh must not be recorded as verified either. Both already behaved correctly; they now have tests so the daily sweep can't start trusting a stamp nobody earned. * Only record a verification something actually proved Review of the previous two commits turned up four issues, all in code they introduced. The stamp lived inside ConnectionVerifier::refreshToken(), which refreshThenVerify() also calls — there the refresh can succeed and the verify that follows still fail. The stamp was already written by then, vouching for a credential nothing confirmed. Normally harmless, because the caller marks the account TokenExpired and recentlyProvenValid() only skips Connected ones, but markAsTokenExpired() silently no-ops when its status lock is held by a concurrent publish. The account then stays Connected with a fresh stamp, and both skip-windows wave it through: 40 minutes before publishing, 12 hours in the daily sweep. Move the stamp to the caller that owns the outcome. TokenRefreshClient classifies on HTTP status alone and never inspects the body, so a 200 carrying an empty access_token is stored as-is and was then recorded as healthy. (A missing key rather than an empty one can't get that far — access_token is NOT NULL, so the write throws first.) Guard on a filled token. The fallback added in 9775578 called verify(), which for an already-expired account — RefreshExpiringTokens selects those too — runs refreshThenVerify() and re-sends the refresh_token the provider just rejected, and on Bluesky re-runs the password re-auth AT Proto rate-limits per account. Its own docblock claimed it only reached the verify endpoint. Add verifyAccessToken(), which checks the stored token and nothing else. VerifyWorkspaceConnections read last_verified_at without ever writing it, so an account it had just confirmed healthy still burned a fresh call minutes later when a post entered the risk window. Stamp on its success path too. Also corrects the RefreshExpiringTokens docblock, which still described the verify-first behaviour removed in c0d0d57. Re-ran the 24h whole-scheduler simulation: still 0 billed reads, 0 expired minutes, account Connected at the end. * Fall back to the token a concurrent refresh persisted The fallback added in 9775578 judged the in-memory access_token, which is exactly the one that is stale when a refresh loses a race. X single-uses the refresh_token, so when two refreshes overlap the loser's token comes back 400 invalid_grant. Its in-memory instance still holds the pair the winner has already rotated away, so verifying it 401s and the account is marked TokenExpired — while the row in the database holds a perfectly healthy token the winner just wrote. Verified against main: a concurrent rotation leaves the account Connected there and TokenExpired here. refreshThenVerify() already handles this by reloading and retrying with whatever was persisted; the new path skipped that because it never went through refreshThenVerify. Reload before judging. This is the failure path only, so the healthy path is untouched — the whole-scheduler simulation still reports 0 billed reads, 0 expired minutes, Connected at the end. * Don't record a verification when the lock skipped the refresh refreshToken() returns normally — no exception — when another process already holds the per-account lock, so the caller can't tell "refreshed" from "did nothing". Moving the stamp out of the verifier in 4d2795a lost that distinction: RefreshSocialToken stamped last_verified_at on a run that made zero HTTP calls. The account is then vouched for by nobody: the daily sweep skips it for 12 hours and the pre-publish check for 40 minutes. If the concurrent refresh also failed, nothing ever confirmed the credential. Have refreshToken() report whether it actually ran. Callers that ignore the return value are unaffected. The test meant to cover this called refreshToken() directly rather than going through the job, so it kept passing while the job path was broken — it now exercises the job and asserts no HTTP call was made. * Close the two concurrency gaps the refresh path leaves open Both are pre-existing, but this branch raises the exposure to them from 12 to 16 refreshes per account per day. The per-account lock lasted 30 seconds, exactly the HTTP client's default read timeout — so a refresh could outlive the lock that protects it. Bluesky is the worst case, refreshing with two sequential calls (refreshSession, then the createSession re-auth), each bounded by connect + read timeouts: up to ~80 seconds under one 30-second lock. Once it lapses, a second process refreshes with the same single-use refresh_token and one of the two is rejected. Name the TTL, set it past the ceiling, and write down the invariant so a future slower refresh doesn't quietly break it. RefreshSocialToken was not unique. RefreshExpiringTokens re-selects an account until token_expires_at moves, and that only happens once the job runs — so a queue more than one tick behind stacked a job per tick for the same account, each rotating a single-use refresh_token again for nothing and widening the gap where a worker death loses the pair. Key it by account like VerifyUpcomingPostConnections already does. Cadence is unchanged: the whole-scheduler simulation still reports 16 refreshes, 0 billed reads and 0 expired minutes over 24h. * Correct which providers actually single-use their refresh_token Checked each provider's official documentation rather than carrying the assumption forward. LinkedIn does not rotate. Its refresh docs are explicit: "the lifespan or Time To Live (TTL) of the refresh token remains the same as specified in the initial OAuth flow (365 days)" — the same token comes back with a decreasing refresh_token_expires_in, and only the access token is reissued. The claim that it single-uses the token predates this branch, but a docblock added here repeated it. Bluesky does rotate, and belongs in the list instead: com.atproto.server .refreshSession declares refreshJwt as a required output field, so every refresh mints a new one. Verified alongside, all matching what the code already does: X access token 2h, refresh single-use with rotation Bluesky refreshJwt rotates; createSession is rate-limited per handle (30/5min, 300/day), which the fallback re-auth path shares TikTok access 24h, refresh 365d, "may be different — you must use the newly-returned token", which refreshTikTokToken does LinkedIn access 60d, refresh 365d fixed, not rotated Google does not rotate on refresh; 100 refresh tokens per account per client, so the higher refresh rate on YouTube carries no rotation risk * Read X post metrics from the timeline that already returned them Analytics fetched the account's timeline for post ids, then turned around and looked the same ids up again through GET /2/tweets purely to read the public_metrics the first request could have returned. The timeline call asked for start_time, end_time and max_results — never tweet.fields. Both endpoints bill per Post returned, so the second pass claimed the same resources a second time, took a second round-trip, and spent a second slice of the same rate limit. For an account with 250 posts in range that is 6 requests where 3 will do. The saving is in round-trips and rate limit rather than dollars: X deduplicates a resource within a 24-hour UTC window, so the second read of an id already read that day is not charged again. But the docs call that a soft guarantee that "may result in resources not being deduplicated" — this stops leaning on it for 250 resources per analytics load. Behaviour is unchanged: same totals, same 5-page ceiling, same empty result when the account posted nothing in range. The page cap is now a named constant, since it bounds what one load can cost as much as how long it takes. Adds the first tests for XAnalytics::getMetrics, covering the totals, the pagination, and that the metrics arrive on the timeline request. * Cover the analytics paths a happy-path test walks straight past Three gaps in what the previous commit's tests actually assert: A timeline page failing mid-pagination breaks out of the loop and returns whatever was collected. Nothing pinned that — partial data beats an exception on a dashboard someone is looking at, and a future refactor could quietly turn it into one. A post can come back without public_metrics. The accumulator defaults each metric to 0, so it contributes nothing instead of erroring, which also wasn't covered. An account with no posts in range returns [] rather than a list of zeros, so the UI can tell "nothing posted" from "posted, no engagement". Also makes the routing test's mock return explicit. Without andReturn, Mockery hands back a falsy default for the new bool return type, so the assertion about routing was passing while silently exercising the lock-skipped branch. The test still asserts only what its name claims, but no longer depends on a mock default to get there. * Close five issues an independent review found in this branch All five sit in code these commits introduced. Instagram and Threads must fail loudly. Their long-lived token is extended in place and cannot be renewed once it expires, and RefreshExpiringTokens picks them up a full day ahead precisely so there is time to react. The fallback added in 9775578 applied to them too, so a permanently rejected extension on a token that still reads left the account Connected — the daily sweep passed as well, since verify() succeeds on it — and the owner learned about it only after the token died unrecoverably, while every tick retried the rejection for 24 hours. The fallback now applies only to platforms that rotate a refresh_token, which is what it was written for. The lock went the wrong way. Lengthening it to 120s in 5001c9a treated the scheduler as the only caller, but publishers wait on the same lock: one left behind by a worker that died mid-refresh makes refreshToken() return false, and the publisher falls through and publishes with an expired token. That window was 30s and had become 120s. Bound the calls instead — a token endpoint answers in milliseconds, and 8s read / 4s connect keeps even Bluesky's two sequential calls under a 30-second lock, back to where main had it. Reloading the account can throw. $this->account->refresh() sat outside the try in the fallback, and an exception raised inside a catch block is not caught by a sibling catch. With tries = 1, an account deleted mid-run put the job in failed_jobs. VerifyUpcomingPostConnections guards this same race explicitly. An empty access token was detected but not acted on. recordVerification() declined to stamp it, yet the refresh still counted as a success — and the refresh method had already pushed token_expires_at two hours out, so the account left the window looking healthy while every publish 401d. Mark it expired, which is what verify() used to do on the same input. refreshToken() claimed "whether a refresh actually ran" but returned true for platforms whose match arm does nothing. Only recordVerification() re-checking hasTokenRefreshFlow() kept that from mattering. The guard is now explicit and the contract true at the source. Also settles the tweet.fields question against the live API rather than the docs, which contradict each other: the OpenAPI spec names the parameter post.fields, while the Fields guide and every example use tweet.fields. Both are accepted and both return public_metrics. An unrecognised name returns 200 and silently omits the field — no error — so the name being right is load bearing, and it is. * Guard the match that no longer has a default arm Dropping `default => null` from refreshToken() in af379af made the return value honest, but it also turned a missing case into an UnhandledMatchError at runtime. The arms and hasTokenRefreshFlow() currently agree on the same nine platforms, and nothing enforces that: adding a platform to the predicate without an arm would fail in production, on a queue worker, for one platform's accounts only. The test walks every platform claiming a refresh flow and fails by name if the match has no arm for it. Verified it catches the real thing by temporarily adding Facebook to the predicate — it failed with "facebook claims a refresh flow but refreshToken() has no arm for it" — rather than trusting a green run on code that already agrees with itself. * Make the per-platform guard fail on a broken client chain The test added in the previous commit swallowed every Throwable except UnhandledMatchError, so it proved a match arm existed and nothing more. Routing all nine refresh methods through refreshHttp() in af379af rewrote how each one builds its request, and this test would have passed just the same if one of those chains no longer worked. It now fails on any exception, naming the platform, and asserts each refresh actually put a request on the wire — a chain that breaks during construction raises before anything is sent, so an empty recording is the signal. Verified by breaking Pinterest's chain on purpose: "pinterest refresh threw BadMethodCallException: Method PendingRequest::withHeadersTypo does not exist." All nine send their request with the chains as they stand. * Stop trading a recoverable failure for an unrecoverable one A max-effort review found six issues, three of which undo a trade the previous round got backwards. Every refresh method wrote the response straight over the stored credential: `'access_token' => data_get($data, 'access_token')`. A 200 carrying no token therefore destroyed a working one — and on Instagram and Threads, where refresh_token is set to the same value, both halves at once. The blank() check added in af379af only noticed after the damage was persisted, then marked the account TokenExpired, which RefreshExpiringTokens no longer selects — so one glitchy-but-successful response emailed the owner and forced a manual reconnect. Guard before the write instead, treat it as the platform misbehaving, and the stored pair survives for the next tick to retry. The detection branch downstream is now unreachable and gone. Tightening the refresh timeout to 8s was the wrong fix for the lock problem. refreshHttp() is shared with 24 publish and analytics call sites, and for X, Bluesky and TikTok the refresh_token is single-use: abandoning a request the provider has already processed loses the rotated pair permanently and costs the user a reconnect. Giving up sooner makes that more likely, not less. Restore generous timeouts and put the lock back above them. The cost of erring long is that a worker dying mid-refresh holds the lock while a publish falls through and retries — recoverable, unlike a lost rotation. Both constants now say which way they are wrong on purpose. The hasTokenRefreshFlow() guard in recordVerification() was dead: refreshToken() already returns false for those platforms, so the branch was never entered. The test claiming to cover it calls refreshToken() directly and never reaches it. The command reported "Dispatched N" for a number it cannot know. dispatch() returns a PendingDispatch whether or not ShouldBeUnique discarded it, so the count overstated itself during exactly the backlog someone reads that line to diagnose. It now reports accounts in the window, which is what it actually measured. Not changed: the daily sweep still skips accounts a refresh keeps fresh. That is the deliberate decision this PR is built on — a refresh replaces the access token rather than inspecting it, so there is nothing left for a billed read to confirm. Re-verified live after the changes: the real job still rotates the token against api.x.com and leaves the account connected. * Cover the tokenless-200 guard on every platform, not just X The guard added in the previous commit protects nine refresh methods; only X had a test. Each provider reads a different field name out of the response, so a regression would land on one platform at a time and the suite would stay green for the other eight. The test drives every platform claiming a refresh flow through a 200 that carries no token, and requires each to refuse with PlatformUnavailableException — nothing is provably dead, so the next tick should retry rather than anyone being disconnected — while leaving the stored credential untouched. Verified it fails usefully by dropping the guard from one platform: 'threads should refuse a tokenless 200 cleanly, got QueryException: null value in column access_token violates not-null constraint'. Without naming the platform the failure reads as an unrelated database error, since the write also poisons the surrounding transaction. * Stop the fallback from reading every failure as good news A fifth review found the concurrent-refresh test passing without ever exercising what it claims to cover, and the reason it could is a real bug. access_token is an encrypted cast. The test wrote the winner's pair with DB::table()->update(), which stores plaintext, so reading it back raised DecryptException — and accessTokenStillWorks() caught Throwable and returned true. Green test, zero coverage of the recovery that justifies the method existing. It now writes through the model and asserts the verify call actually happened; removing the reload makes it fail. The catch is the bug. Treating any non-TokenExpiredException as "the token is healthy" means an APP_KEY rotation, a corrupted column, or an UnhandledMatchError from a newly added platform leaves the account Connected forever while every publish hard-fails, and nobody is told. It now names the outcomes that earn the benefit of the doubt — platform down, network dropped, account deleted mid-run — and lets the rest surface. Loud is right here: an APP_KEY rotation breaks every account at once, so failing the job where an operator sees it beats disconnecting every user. Refusing to persist a tokenless 200 also had no way out. The account kept retrying every 15 minutes forever, and the daily sweep counts PlatformUnavailableException as verified, so it was never disconnected and never reported. Now the retry only continues while there is a live token behind it: once that expires and renewal still fails, the connection is dead in practice and says so. Also corrects the refreshHttp() docblock, which claimed a blast radius the private method does not have — refreshToken() is what those 24 call sites reach — and records in VerifyWorkspaceConnections that short-TTL platforms never being re-verified is the intended consequence, not an oversight. * Stop a bad hour at the provider from disconnecting anyone The escalation added last round was wrong, and two neighbouring paths had the same shape of bug. Marking the account expired whenever a PlatformUnavailableException hit an already-expired token looked like it closed a silent-rot gap. But that exception is what TokenRefreshClient raises for 5xx, 429 and connection timeouts — so X rate-limiting for forty minutes around a two-hour token's expiry disconnected the account, emailed the owner, and hard-failed every scheduled post, with only the daily sweep to undo it. The rot it was meant to prevent surfaces at publish time anyway. Reverted: a transient failure never disconnects. refresh_token was left unguarded when access_token was hardened. data_get() only falls back when a key is absent, so a provider answering with an explicit "refresh_token": null wiped the stored one — and the next tick then threw "no refresh token available" without making a single call. Guarded in the same four places, falling back on blank rather than on missing. A held lock reported "nothing refreshed" even when the token was already dead, handing the caller a credential it knew was expired. The publisher posts with it, takes a 401, and PublishToSocialPlatform finalises the post as failed and disconnects the account — over a lock a dying worker left behind, for the two minutes it survives. It now says transient, which is what a refresh someone else is already running actually is. VerifyWorkspaceConnections promoted TokenExpired accounts back to Connected on verifyAccount()'s return value, which is also true for "could not check, don't disconnect". An unreachable platform therefore told owners their reconnect had worked when nothing was verified. Promotion moved next to the successful verify. Each of the four is pinned by a test, and each test was checked by reverting the fix and confirming it fails. * Keep a lock collision off the analytics page Round seven found one real regression from round six, one consistency gap, and one stale comment. Reporting lock contention as transient was right for the publish path, which reschedules, but analytics calls refreshToken() bare and AnalyticsController has no try/catch — and there is no renderable handler for PlatformUnavailableException. A user opening analytics for an account whose token expired while the scheduled job held the lock got an HTTP 500 where the same request previously returned empty metrics. Reproduced before fixing: "Expected response status code [200] but received 500". The controller now degrades to empty numbers and still reports, which also covers the same 500 for any platform whose refresh 5xx'd — possible before this branch too. The fallback verify was throwing away a result worth keeping. It is a billed call on X and it proves the token alive exactly as a refresh does, so the pre-publish check was paying to ask the same question minutes later. Stamped like the other two sites. Also rewrites a comment in rotatedTokenFrom() that described the data_get() call it replaced rather than the blank() check beneath it. The review also reported a false @throws on that method; it has no docblock at all. Both fixes checked by reverting them and confirming the new tests fail. * Degrade analytics on an unreachable platform, not on a bug The rescue() added last commit caught Throwable, so it did not just absorb a platform being down — it absorbed everything. A TypeError in any metrics service rendered as "this account has no activity", with a log line as the only sign anything was wrong. Reproduced: a metrics service throwing RuntimeException returned 200 with empty metrics. This is the same mistake the review flagged two rounds ago in accessTokenStillWorks(), where treating any exception as "the token is healthy" hid real failures. Narrowed the same way: PlatformUnavailableException and ConnectionException degrade to empty numbers and still report, everything else surfaces as the 500 it is. The match moved into a named method so the intent has somewhere to live, since the reason for the narrow catch matters more than the catch itself. Both directions are pinned: widening the catch back to Throwable fails the bug-is-not-hidden test, and removing the degradation fails the lock-collision test. * Trim the commentary back to what the code cannot say RefreshSocialToken had 72 comment lines against 95 of code — 43% of the file. The rest of the branch was heading the same way: two constants in ConnectionVerifier carried twelve- and eight-line docblocks, and VERIFIED_WITHIN_HOURS had twelve lines explaining a number. Most of it was history rather than reasoning: what the code used to do, which review round asked for a change, the full argument for a decision the code already states. Kept the parts a reader cannot recover — why a catch is narrow, why a stamp is not written inside refreshToken(), why the lock has to outlast the timeouts — and cut the rest. shouldTrustAWorkingAccessToken() went with it: a one-line method behind a twelve-line docblock, now the condition it wrapped, inline where it is used. No behaviour change; full suite unchanged at 3786.
2026-08-20 20:58:16 +00:00
test('a backed-up queue cannot stack duplicate refresh jobs for one account', function () {
Queue::fake();
SocialAccount::factory()->x()->create([
'workspace_id' => Workspace::factory()->create()->id,
'status' => Status::Connected,
'token_expires_at' => now()->addMinutes(20),
]);
// Two scheduler ticks before the first job got a worker: token_expires_at
// has not moved, so the account is still inside the window.
$this->artisan('social:refresh-expiring-tokens');
$this->artisan('social:refresh-expiring-tokens');
// Each extra job rotates a single-use refresh_token again for nothing, and
// widens the window where a worker death loses the pair.
Queue::assertPushed(RefreshSocialToken::class, 1);
});
test('the command reports accounts in the window, not jobs it cannot know landed', function () {
Queue::fake();
SocialAccount::factory()->x()->create([
'workspace_id' => Workspace::factory()->create()->id,
'status' => Status::Connected,
'token_expires_at' => now()->addMinutes(20),
]);
// RefreshSocialToken is unique per account, so a second dispatch while the
// first is in flight is silently discarded. dispatch() still returns a
// PendingDispatch either way, so a "dispatched" count would be a guess.
$this->artisan('social:refresh-expiring-tokens')
->expectsOutput('1 accounts due for a token refresh.');
});