Skip to content

fix(social): unify token-expired handling across all publishers#29

Merged
paulocastellano merged 5 commits into
mainfrom
fix/social-token-expired-handling
May 12, 2026
Merged

fix(social): unify token-expired handling across all publishers#29
paulocastellano merged 5 commits into
mainfrom
fix/social-token-expired-handling

Conversation

@paulocastellano
Copy link
Copy Markdown
Contributor

@paulocastellano paulocastellano commented May 12, 2026

Context

Scheduled X post failed with the cryptic "An unknown X error occurred." despite the user having recently run social:refresh-expiring-tokens. The audit revealed the same latent bug pattern in 6 publishers: when the OAuth refresh endpoint rejects the stored refresh_token, the failure was misclassified as a generic publish error instead of a token-expired event — so the user never got a clear "reconnect your account" signal and the account never transitioned cleanly to Status::TokenExpired.

This PR fixes the entire class of bug, not just X.

The 4 fixes

1. PublishToSocialPlatform — fail-fast on Status::TokenExpired

Mirrors the existing Status::Disconnected guard. When the account is already known to need reconnection, mark the platform as failed with posts.errors.account_token_expired + error_context.category = 'token_expired' and skip the publish attempt entirely. Applies to all platforms (status-based, not platform-specific).

2. Publishers' refreshTokenTokenExpiredException (XPublisher + 5 others)

For every publisher with its own OAuth refresh logic (X, LinkedIn, LinkedInPage, Pinterest, Threads, TikTok), when the refresh endpoint returns a failure response, throw TokenExpiredException directly with:

  • message: extracted from the provider's error_description / error.message
  • platformErrorCode: the HTTP status

This bypasses each <Platform>PublishException::fromApiResponse, which was designed for the publish-API response shape (type/title/detail, status 401 detection) and didn't reliably classify OAuth-format refresh failures (status 400 + error/error_description).

The redundant Log::error calls were dropped — the downstream catch in PublishToSocialPlatform::handle already logs the exception (and Nightwatch captures it), and the actionable info now lives on the exception itself.

Instagram and YouTube were already doing this correctly; no changes there.

3. SocialAccount::markAsTokenExpired — notify the user

Mirrors the pattern in markAsDisconnected:

  • Lock to prevent duplicate notifications on concurrent transitions
  • Detect the transition from ConnectedTokenExpired (no spam on subsequent failures)
  • Dispatch SendNotification (in-app + email) with Type::AccountDisconnected

Optional bool $notify = true parameter lets VerifyWorkspaceConnections suppress per-account notifications when it's about to send a batched summary email.

4. i18n

New key posts.errors.account_token_expired in en / pt-BR / es.

Test plan

  • php artisan test --compact --parallel1493 passed, 2 skipped, 0 failed (+5 over main, all new)
  • New: publish to social platform skips publishing when account token is expired
  • New: x publisher throws TokenExpiredException when refresh_token is rejected by X
  • New: linkedin publisher throws TokenExpiredException when refresh_token is rejected
  • New: linkedin page publisher throws TokenExpiredException when refresh_token is rejected
  • New: pinterest publisher throws TokenExpiredException when refresh_token is rejected
  • New: threads publisher throws TokenExpiredException when refresh_token is rejected
  • New: tiktok publisher throws TokenExpiredException when refresh_token is rejected
  • New: 3 tests covering SocialAccount::markAsTokenExpired notification behavior
  • Removed obsolete test publish to social platform attempts publishing when account token is expired (asserted the buggy behavior)
  • Manual: schedule a post against an account whose refresh_token was rotated/revoked → confirm posts.errors.account_token_expired shows up (not "unknown")
  • Manual: trigger markAsTokenExpired → confirm in-app notification + email arrives once per transition

Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:

1. **PublishToSocialPlatform**: fail-fast when account status is
   `TokenExpired`. Previously the job tried to publish, the publisher
   internally tried to refresh, the provider rejected the rotated
   refresh_token, and the failure surfaced as a generic 'unknown' error
   instead of a clear 'reconnect your account' signal.

2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
   refresh_token (typically because it was rotated/revoked at X), log the
   raw response and throw `TokenExpiredException` instead of falling
   through to `XPublishException::fromApiResponse` which expects the
   tweet-API response shape (`type`/`title`/`detail`) and treats
   OAuth-style responses (`error`/`error_description`) as 'Unknown'.

3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
   notification (`Type::AccountDisconnected`) when an account
   transitions from `Connected` → `TokenExpired`, mirroring the
   existing pattern in `markAsDisconnected`. Wrapped in a lock to
   prevent duplicate notifications on concurrent transitions. Accepts an
   optional `notify: false` so the batch verifier
   (`VerifyWorkspaceConnections`) can suppress per-account
   notifications and rely on its summary email.
Extends the X-only fix to LinkedIn, LinkedInPage, Pinterest, Threads, and
TikTok. Each publisher's `refreshToken` now throws `TokenExpiredException`
directly when the OAuth refresh endpoint rejects the refresh_token, instead
of routing through `handleApiError` -> `<Platform>PublishException::fromApiResponse`.

Why: `fromApiResponse` is designed for the publish API response shape and
only converts certain status codes to `TokenExpiredException` (e.g., 401).
OAuth refresh failures typically return 400 with an `error`/
`error_description` (or platform variant) body and were falling into the
generic 'Unknown' bucket, masking the real cause and skipping the proper
`markAsTokenExpired` + user-notification flow.

Dropped the redundant `Log::error` before each `throw`: the downstream
catch in `PublishToSocialPlatform::handle` already logs the exception
(captured by Nightwatch), and the rich context now lives on the exception
itself (`message` = provider's `error_description` / `error.message`,
`platformErrorCode` = HTTP status).

Tests: one regression-style test per publisher confirming refresh rejection
becomes `TokenExpiredException` instead of a generic publish exception.
@paulocastellano paulocastellano changed the title fix(social): handle TokenExpired status fail-fast and notify user fix(social): unify token-expired handling across all publishers May 12, 2026
These tests use Http::fake, model factories, and DB — by Laravel/Pest
convention that's a feature test, not a unit test. Moving them to the
Feature suite to match the convention.

No code changes. Tests still pass: 1493 passed, 2 skipped, 0 failed.
Two follow-up fixes from the code review:

1. **Unified lock key for markAsDisconnected / markAsTokenExpired.**
   Both methods now use `social_account_status:{id}` instead of
   different keys. Prevents the race where `markAsDisconnected` and
   `markAsTokenExpired` could run concurrently on the same account
   (publish-time vs verify-batch-time), causing overlapping updates and
   duplicate notifications.

2. **i18n for notification title/body in markAsTokenExpired and
   markAsDisconnected.** Strings were previously hardcoded in English.
   Added `notifications.account_disconnected.{title,body}` and
   `notifications.account_token_expired.{title,body}` in en, pt-BR, es.
   Follows the project convention (e.g. `Mail/PostPublished`) of
   concatenating the `@` prefix in PHP before passing the username to
   the translation placeholder, instead of putting `@:account` in the
   lang file.
…cted_at preservation, and i18n placeholder substitution

Fills gaps surfaced in the code review:

- 3 new `markAsDisconnected` tests (mirroring the existing TokenExpired
  trio). Previously the method had zero coverage despite this PR
  changing its lock key.
- 1 test confirming `markAsTokenExpired($msg, notify: false)` skips
  notification dispatch (used by VerifyWorkspaceConnections batch path).
- 1 test confirming `disconnected_at` is preserved when already set
  (the `?? now()` branch).
- 2 end-to-end tests (one per method) that DO NOT fake the queue, so
  `SendNotification::handle()` runs synchronously. Asserts the
  Notification row is actually created in the DB with the correct
  i18n-substituted title/body, that NotificationCreated event is
  dispatched, and that AccountDisconnected mail is queued. Catches
  bugs where the i18n placeholder keys (`:platform`, `:account`)
  silently fail to substitute.
@paulocastellano paulocastellano merged commit 82cf8ae into main May 12, 2026
2 checks passed
@paulocastellano paulocastellano deleted the fix/social-token-expired-handling branch May 12, 2026 22:18
paulocastellano added a commit that referenced this pull request May 12, 2026
Five small fixes scoped to the publishReel method:

1. Replace generic \Exception on media download failure with a typed
   FacebookPublishException(ServerError). The generic exception was
   landing in the \Throwable catch in PublishToSocialPlatform with
   category 'unknown', defeating the whole point of the social
   exception hierarchy.

2. Replace handleApiError($startResponse) with a direct
   FacebookPublishException throw when video_id/upload_url are missing.
   The previous code passed a successful HTTP response into a method
   built for error responses — fromApiResponse would fall into the
   default arm and surface 'An unknown Facebook error occurred.'
   ironically reintroducing the same bad UX we just spent the day
   fixing.

3. Drop the four redundant Log::error calls before handleApiError.
   FacebookPublishException::fromApiResponse already pulls the FB
   error code/subcode/message into platformErrorCode + userMessage,
   and the downstream catch in PublishToSocialPlatform::handle logs
   the exception anyway (Nightwatch picks that up). Same pattern as
   the X cleanup in PR #29.

4. Stream the upload body via fopen() resource instead of
   file_get_contents(). Eliminates loading the whole video into memory
   for large reels.

5. Replace \@Unlink with unlink + Log::warning. Surfaces temp-file
   cleanup failures instead of silently leaking files.

Tests:
- Strengthened the missing-upload_url test to assert the exception
  message and class.
- Added a typed-exception test for the media-download failure path
  (would have caught the regression where we used a generic
  \Exception).
- Full suite green: 1505 passed, 2 skipped, 0 failed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant