Merge branch 'main' into feat/content_languages
This commit is contained in:
commit
56d7fa4033
44 changed files with 830 additions and 110 deletions
|
|
@ -24,7 +24,7 @@ public static function execute(User $user, array $data): Workspace
|
|||
'brand_color' => data_get($data, 'brand_color'),
|
||||
'background_color' => data_get($data, 'background_color'),
|
||||
'text_color' => data_get($data, 'text_color'),
|
||||
'content_language' => data_get($data, 'content_language'),
|
||||
'content_language' => data_get($data, 'content_language', app()->getLocale()),
|
||||
], static fn ($value): bool => $value !== null);
|
||||
|
||||
$workspace = DB::transaction(function () use ($user, $attributes): Workspace {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,25 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Jobs\RefreshSocialToken;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RefreshExpiringTokens extends Command
|
||||
{
|
||||
protected $signature = 'social:refresh-expiring-tokens';
|
||||
|
||||
protected $description = 'Proactively refresh tokens expiring in the next 2 hours (or already expired)';
|
||||
protected $description = 'Proactively refresh social tokens before they expire';
|
||||
|
||||
/**
|
||||
* Rotating refresh_token platforms only need a short lead: verify() won't
|
||||
* rotate a still-valid token, so we catch them right before or after expiry.
|
||||
* Extension-model platforms (Instagram/Threads) can't be refreshed once
|
||||
* expired, so they get a much wider lead to survive queue backlog.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$count = 0;
|
||||
|
|
@ -22,7 +30,15 @@ public function handle(): void
|
|||
SocialAccount::query()
|
||||
->where('status', Status::Connected)
|
||||
->whereNotNull('token_expires_at')
|
||||
->where('token_expires_at', '<=', now()->addHours(2))
|
||||
->where(function (Builder $query) {
|
||||
$query->where(function (Builder $extension) {
|
||||
$extension->whereIn('platform', Platform::accessTokenExtendingPlatformValues())
|
||||
->where('token_expires_at', '<=', now()->addDay());
|
||||
})->orWhere(function (Builder $rotating) {
|
||||
$rotating->whereNotIn('platform', Platform::accessTokenExtendingPlatformValues())
|
||||
->where('token_expires_at', '<=', now()->addMinutes(30));
|
||||
});
|
||||
})
|
||||
->chunk(50, function ($accounts) use (&$count) {
|
||||
foreach ($accounts as $account) {
|
||||
RefreshSocialToken::dispatch($account);
|
||||
|
|
|
|||
|
|
@ -261,6 +261,60 @@ public function requiresContent(): bool
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this platform refreshes by extending the access_token itself
|
||||
* (Instagram/Threads long-lived tokens) instead of exchanging a separate
|
||||
* refresh_token. Extension-model tokens cannot be refreshed once expired,
|
||||
* so they must be refreshed proactively while still valid — the opposite
|
||||
* of rotating refresh_token platforms, which we avoid refreshing until
|
||||
* they actually expire so we don't rotate a still-valid single-use token.
|
||||
*/
|
||||
public function extendsAccessTokenOnRefresh(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::Instagram, self::Threads => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `platform` column values of the platforms that refresh by extending
|
||||
* their access token in place (Instagram and Threads — see
|
||||
* extendsAccessTokenOnRefresh), for use in database whereIn/whereNotIn
|
||||
* filters. Derived from extendsAccessTokenOnRefresh() so the two never drift.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function accessTokenExtendingPlatformValues(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (self $platform): string => $platform->value,
|
||||
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token lifetime, in seconds, to assume when the provider's OAuth
|
||||
* response omits expires_in. Each value is that network's own documented
|
||||
* default:
|
||||
*
|
||||
* - X: a 2-hour access token.
|
||||
* - Instagram / Threads: Meta's 60-day long-lived token.
|
||||
*
|
||||
* Networks that always return expires_in (LinkedIn, TikTok, YouTube,
|
||||
* Pinterest), whose refresh sets a fixed lifetime directly (Bluesky), or
|
||||
* whose tokens never expire (Facebook, Mastodon, Telegram, Discord) have no
|
||||
* fallback here and return null.
|
||||
*/
|
||||
public function defaultTokenTtlSeconds(): ?int
|
||||
{
|
||||
return match ($this) {
|
||||
self::X => 7200,
|
||||
self::Instagram, self::Threads => 5184000,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function queue(): string
|
||||
{
|
||||
return 'social-'.$this->value;
|
||||
|
|
|
|||
|
|
@ -150,9 +150,15 @@ public function connect(Request $request): Response|RedirectResponse
|
|||
'network' => $platform->network(),
|
||||
])->values();
|
||||
|
||||
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
|
||||
|
||||
return Inertia::render('onboarding/Connect', [
|
||||
'platforms' => $platforms,
|
||||
'accounts' => SocialAccountResource::collection($accounts)->resolve(),
|
||||
'plan' => [
|
||||
'name' => $plan->name,
|
||||
'interval' => 'monthly',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ public function callback(Request $request): InertiaResponse
|
|||
$avatarPath = $socialUser->getAvatar() ? uploadFromUrl($socialUser->getAvatar()) : null;
|
||||
|
||||
// Calculate token expiration (long-lived tokens last 60 days)
|
||||
$expiresIn = $socialUser->expiresIn ?? 5184000; // 60 days in seconds
|
||||
$expiresIn = $socialUser->expiresIn ?? $this->platform->defaultTokenTtlSeconds();
|
||||
$tokenExpiresAt = now()->addSeconds($expiresIn);
|
||||
|
||||
$workspace->socialAccounts()->updateOrCreate(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\TokenRedactor;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -90,7 +91,7 @@ public function callback(Request $request): InertiaResponse
|
|||
if ($tokenResponse->failed()) {
|
||||
Log::error('Threads token exchange failed', [
|
||||
'status' => $tokenResponse->status(),
|
||||
'body' => $tokenResponse->body(),
|
||||
'body' => TokenRedactor::redact($tokenResponse->body()),
|
||||
]);
|
||||
throw new \Exception('Failed to exchange token');
|
||||
}
|
||||
|
|
@ -106,15 +107,18 @@ public function callback(Request $request): InertiaResponse
|
|||
'access_token' => $shortLivedToken,
|
||||
]);
|
||||
|
||||
$longLivedToken = $shortLivedToken;
|
||||
$expiresIn = null;
|
||||
|
||||
if ($longLivedResponse->successful()) {
|
||||
$longLivedData = $longLivedResponse->json();
|
||||
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
|
||||
$expiresIn = $longLivedData['expires_in'] ?? null;
|
||||
if ($longLivedResponse->failed()) {
|
||||
Log::error('Threads long-lived token exchange failed', [
|
||||
'status' => $longLivedResponse->status(),
|
||||
'body' => TokenRedactor::redact($longLivedResponse->body()),
|
||||
]);
|
||||
throw new \Exception('Failed to exchange long-lived token');
|
||||
}
|
||||
|
||||
$longLivedData = $longLivedResponse->json();
|
||||
$longLivedToken = $longLivedData['access_token'] ?? $shortLivedToken;
|
||||
$expiresIn = $longLivedData['expires_in'] ?? $this->platform->defaultTokenTtlSeconds();
|
||||
|
||||
// Fetch user profile
|
||||
$profileResponse = Http::get(config('trypost.platforms.threads.graph_api')."/{$userId}", [
|
||||
'access_token' => $longLivedToken,
|
||||
|
|
@ -142,7 +146,7 @@ public function callback(Request $request): InertiaResponse
|
|||
'avatar_url' => $avatarPath,
|
||||
'access_token' => $longLivedToken,
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => $expiresIn ? now()->addSeconds($expiresIn) : null,
|
||||
'token_expires_at' => now()->addSeconds($expiresIn),
|
||||
'scopes' => $this->scopes,
|
||||
'status' => Status::Connected,
|
||||
'error_message' => null,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,14 @@ public function __construct(public SocialAccount $account) {}
|
|||
public function handle(ConnectionVerifier $verifier): void
|
||||
{
|
||||
try {
|
||||
$verifier->refreshToken($this->account);
|
||||
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
|
||||
// Instagram/Threads extend the long-lived token itself and
|
||||
// can't be refreshed once expired, so extend it while it's
|
||||
// still valid instead of waiting for it to fail.
|
||||
$verifier->refreshToken($this->account);
|
||||
} else {
|
||||
$verifier->verify($this->account);
|
||||
}
|
||||
} catch (PlatformUnavailableException $e) {
|
||||
Log::warning('Token refresh skipped: platform unavailable', [
|
||||
'account_id' => $this->account->id,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,19 @@ protected function isTokenExpiringSoon(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the token should be refreshed before use. Rotating-refresh-token
|
||||
* platforms are only refreshed once actually expired, to avoid rotating a
|
||||
* still-valid single-use refresh_token; extension-model platforms
|
||||
* (Instagram/Threads) must be refreshed while still valid because their
|
||||
* token can't be extended once expired.
|
||||
*/
|
||||
public function needsProactiveTokenRefresh(): bool
|
||||
{
|
||||
return $this->is_token_expired
|
||||
|| ($this->platform->extendsAccessTokenOnRefresh() && $this->is_token_expiring_soon);
|
||||
}
|
||||
|
||||
protected function avatarUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class GiphyService
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.giphy.api_key', '');
|
||||
$this->apiKey = (string) config('services.giphy.api_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$this->account = $postPlatform->socialAccount;
|
||||
$this->hasRetried = false;
|
||||
|
||||
if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) {
|
||||
if ($this->account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($this->account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
|
||||
|
||||
// Refresh token if needed
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Discord\DiscordClient;
|
||||
use App\Services\Social\Meta\GraphError;
|
||||
use App\Services\Social\Telegram\TelegramApi;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -31,9 +32,7 @@ public function verify(SocialAccount $account): bool
|
|||
// refresh, so proactive refreshes during races cause false-positive
|
||||
// disconnects even though the access_token still works fine.
|
||||
if ($account->is_token_expired) {
|
||||
$this->refreshToken($account);
|
||||
|
||||
return $this->callVerifyEndpoint($account);
|
||||
return $this->refreshThenVerify($account);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -41,13 +40,40 @@ public function verify(SocialAccount $account): bool
|
|||
} catch (TokenExpiredException $e) {
|
||||
// Verify returned 401: the access_token is actually invalid.
|
||||
// Refresh and retry once with the new token.
|
||||
try {
|
||||
$this->refreshToken($account);
|
||||
} catch (TokenExpiredException) {
|
||||
throw $e;
|
||||
}
|
||||
return $this->refreshThenVerify($account, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the token, then verify with the new one.
|
||||
*
|
||||
* If either the refresh is rejected (4xx) or the verify that follows it hits
|
||||
* a token a concurrent refresh has already rotated — including the sub-commit
|
||||
* window where a lock-skipped refresh reloads a not-yet-persisted token —
|
||||
* reload and, when another process has since persisted a fresh access_token,
|
||||
* verify with that instead of giving up. X (and other providers that
|
||||
* single-use their refresh_token) would otherwise disconnect a still-usable
|
||||
* account whenever two refreshes race and one loses.
|
||||
*
|
||||
* @throws TokenExpiredException
|
||||
* @throws PlatformUnavailableException
|
||||
*/
|
||||
private function refreshThenVerify(SocialAccount $account, ?TokenExpiredException $original = null): bool
|
||||
{
|
||||
$accessTokenBeforeRefresh = $account->access_token;
|
||||
|
||||
try {
|
||||
$this->refreshToken($account);
|
||||
|
||||
return $this->callVerifyEndpoint($account);
|
||||
} catch (TokenExpiredException $e) {
|
||||
$account->refresh();
|
||||
|
||||
if ($account->access_token !== $accessTokenBeforeRefresh) {
|
||||
return $this->callVerifyEndpoint($account);
|
||||
}
|
||||
|
||||
throw $original ?? $e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +181,7 @@ private function refreshXToken(SocialAccount $account): void
|
|||
$account->update([
|
||||
'access_token' => data_get($data, 'access_token'),
|
||||
'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token),
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)),
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
|
|
@ -288,10 +314,13 @@ private function refreshPinterestToken(SocialAccount $account): void
|
|||
private function refreshThreadsToken(SocialAccount $account): void
|
||||
{
|
||||
// Threads uses long-lived tokens that can be refreshed
|
||||
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'th_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]));
|
||||
$response = TokenRefreshClient::for(Platform::Threads)->send(
|
||||
fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'th_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]),
|
||||
GraphError::indicatesInvalidToken(...),
|
||||
);
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
|
@ -299,7 +328,7 @@ private function refreshThreadsToken(SocialAccount $account): void
|
|||
$account->update([
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
|
|
@ -307,10 +336,13 @@ private function refreshThreadsToken(SocialAccount $account): void
|
|||
|
||||
private function refreshInstagramToken(SocialAccount $account): void
|
||||
{
|
||||
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]));
|
||||
$response = TokenRefreshClient::for(Platform::Instagram)->send(
|
||||
fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
|
||||
'grant_type' => 'ig_refresh_token',
|
||||
'access_token' => $account->access_token,
|
||||
]),
|
||||
GraphError::indicatesInvalidToken(...),
|
||||
);
|
||||
|
||||
$data = $response->json();
|
||||
$newToken = data_get($data, 'access_token');
|
||||
|
|
@ -318,7 +350,7 @@ private function refreshInstagramToken(SocialAccount $account): void
|
|||
$account->update([
|
||||
'access_token' => $newToken,
|
||||
'refresh_token' => $newToken,
|
||||
'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null,
|
||||
'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', $account->platform->defaultTokenTtlSeconds())),
|
||||
]);
|
||||
|
||||
$account->refresh();
|
||||
|
|
@ -385,13 +417,8 @@ private function verifyInstagram(SocialAccount $account): bool
|
|||
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
if (isset($body['error'])) {
|
||||
$errorCode = $body['error']['code'] ?? null;
|
||||
$errorType = $body['error']['type'] ?? null;
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
||||
throw new TokenExpiredException('Instagram access token is invalid or expired');
|
||||
}
|
||||
if (GraphError::indicatesInvalidToken($body)) {
|
||||
throw new TokenExpiredException('Instagram access token is invalid or expired');
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
|
|
@ -406,13 +433,8 @@ private function verifyFacebook(SocialAccount $account): bool
|
|||
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
if (isset($body['error'])) {
|
||||
$errorCode = $body['error']['code'] ?? null;
|
||||
$errorType = $body['error']['type'] ?? null;
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
||||
throw new TokenExpiredException('Facebook access token is invalid or expired');
|
||||
}
|
||||
if (GraphError::indicatesInvalidToken($body)) {
|
||||
throw new TokenExpiredException('Facebook access token is invalid or expired');
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
|
|
@ -427,13 +449,8 @@ private function verifyThreads(SocialAccount $account): bool
|
|||
|
||||
$body = $response->json() ?? [];
|
||||
|
||||
if (isset($body['error'])) {
|
||||
$errorCode = $body['error']['code'] ?? null;
|
||||
$errorType = $body['error']['type'] ?? null;
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190) {
|
||||
throw new TokenExpiredException('Threads access token is invalid or expired');
|
||||
}
|
||||
if (GraphError::indicatesInvalidToken($body)) {
|
||||
throw new TokenExpiredException('Threads access token is invalid or expired');
|
||||
}
|
||||
|
||||
return $response->successful();
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si
|
|||
{
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$account = $postPlatform->socialAccount;
|
||||
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
30
app/Services/Social/Meta/GraphError.php
Normal file
30
app/Services/Social/Meta/GraphError.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Meta;
|
||||
|
||||
/**
|
||||
* Interprets Meta Graph API (Facebook / Instagram / Threads) error responses.
|
||||
*
|
||||
* Meta returns rate-limit and transient failures as an ordinary HTTP 4xx with
|
||||
* type "OAuthException" (e.g. code 4 / 17 "too many calls", code 1 / 2
|
||||
* "temporary problem"), so neither the HTTP status nor the error type can tell
|
||||
* a dead token from a throttle. Only error code 190 means the access token
|
||||
* itself is invalid or expired — the same signal the platform publish
|
||||
* exceptions already use to decide a disconnect.
|
||||
*/
|
||||
class GraphError
|
||||
{
|
||||
/**
|
||||
* Whether the given Meta Graph error body means the access token is
|
||||
* genuinely invalid or expired (code 190), as opposed to a rate-limit or
|
||||
* transient error that must not disconnect a still-valid token.
|
||||
*
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
public static function indicatesInvalidToken(?array $body): bool
|
||||
{
|
||||
return data_get($body, 'error.code') === 190;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -407,7 +407,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
|
|||
*/
|
||||
public function getBoards(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public function getMetrics(SocialAccount $account): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public function fetch(SocialAccount $account): array
|
|||
*/
|
||||
private function fetchFresh(SocialAccount $account): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,18 @@
|
|||
* Normalizes the failure modes of an OAuth token-refresh request:
|
||||
*
|
||||
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
|
||||
* - HTTP 5xx → PlatformUnavailableException
|
||||
* - HTTP 4xx → TokenExpiredException
|
||||
* - HTTP 5xx or 429 → PlatformUnavailableException
|
||||
* - other HTTP 4xx → TokenExpiredException
|
||||
*
|
||||
* Providers that return rate-limit or transient errors as an ordinary 4xx
|
||||
* (Meta: Instagram/Threads) can pass an `$isTokenInvalid` classifier to
|
||||
* `send()`; only a genuinely dead token then disconnects, while every other
|
||||
* 4xx is treated as transient (PlatformUnavailableException).
|
||||
*
|
||||
* Callers configure the actual HTTP call through the closure passed to
|
||||
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
|
||||
* basic auth, etc.) stay where they belong — in the per-platform refresh
|
||||
* method — while the failure semantics are uniform across providers.
|
||||
* method.
|
||||
*/
|
||||
class TokenRefreshClient
|
||||
{
|
||||
|
|
@ -35,11 +40,14 @@ public static function for(Platform $platform): self
|
|||
|
||||
/**
|
||||
* @param Closure():Response $request
|
||||
* @param (Closure(array<string, mixed>|null):bool)|null $isTokenInvalid Given the parsed
|
||||
* response body, returns whether a non-5xx/429 failure means the token itself is dead.
|
||||
* When it returns false, the failure is treated as transient (PlatformUnavailableException).
|
||||
*
|
||||
* @throws PlatformUnavailableException
|
||||
* @throws TokenExpiredException
|
||||
*/
|
||||
public function send(Closure $request): Response
|
||||
public function send(Closure $request, ?Closure $isTokenInvalid = null): Response
|
||||
{
|
||||
$name = $this->platform->label();
|
||||
|
||||
|
|
@ -62,6 +70,14 @@ public function send(Closure $request): Response
|
|||
]);
|
||||
|
||||
$body = $response->json();
|
||||
|
||||
if ($isTokenInvalid !== null && ! $isTokenInvalid($body)) {
|
||||
throw new PlatformUnavailableException(
|
||||
"{$name} API returned {$response->status()} during token refresh",
|
||||
$response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
$message = data_get($body, 'error_description')
|
||||
?? data_get($body, 'error.message')
|
||||
?? "Failed to refresh {$name} token";
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +161,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
// Refresh token if expired or expiring soon
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
return ['unsupported' => true, 'reason' => 'missing_post_id'];
|
||||
}
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
|
|||
|
||||
private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array
|
||||
{
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
if ($account->needsProactiveTokenRefresh()) {
|
||||
app(ConnectionVerifier::class)->refreshToken($account);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ class UnsplashService
|
|||
|
||||
public function __construct()
|
||||
{
|
||||
$this->accessKey = config('services.unsplash.access_key', '');
|
||||
$this->accessKey = (string) config('services.unsplash.access_key', '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,14 +9,18 @@ import NetworkConnectGrid, {
|
|||
type ConnectedAccount,
|
||||
} from '@/components/accounts/NetworkConnectGrid.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTracking } from '@/composables/useTracking';
|
||||
|
||||
const props = defineProps<{
|
||||
platforms: AvailablePlatform[];
|
||||
accounts: ConnectedAccount[];
|
||||
plan: { name: string; interval: string };
|
||||
}>();
|
||||
|
||||
const form = useForm({});
|
||||
|
||||
const { trackBeginCheckout } = useTracking();
|
||||
|
||||
const hasConnected = computed((): boolean => props.accounts.length > 0);
|
||||
|
||||
const submit = (): void => {
|
||||
|
|
@ -24,6 +28,8 @@ const submit = (): void => {
|
|||
return;
|
||||
}
|
||||
|
||||
trackBeginCheckout({ name: props.plan.name, interval: props.plan.interval });
|
||||
|
||||
form.post(checkout.url());
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
Schedule::command(ProcessScheduledPosts::class)->everyMinute()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(CheckSocialConnections::class)->daily()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(RefreshExpiringTokens::class)->hourly()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(FireScheduleTriggers::class)->everyMinute()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer();
|
||||
|
|
|
|||
|
|
@ -42,6 +42,28 @@
|
|||
expect($member?->pivot->role)->toBe(Role::Admin->value);
|
||||
});
|
||||
|
||||
test('CreateWorkspace inherits the app locale as content_language when none is given', function () {
|
||||
app()->setLocale('pt-BR');
|
||||
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
$workspace = CreateWorkspace::execute($user, ['name' => 'Acme']);
|
||||
|
||||
expect($workspace->content_language)->toBe('pt-BR');
|
||||
});
|
||||
|
||||
test('CreateWorkspace keeps an explicit content_language over the app locale', function () {
|
||||
app()->setLocale('pt-BR');
|
||||
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
||||
$workspace = CreateWorkspace::execute($user, ['name' => 'Acme', 'content_language' => 'es']);
|
||||
|
||||
expect($workspace->content_language)->toBe('es');
|
||||
});
|
||||
|
||||
test('CreateWorkspace ignores unknown extra keys like logo_url', function () {
|
||||
$account = Account::factory()->create();
|
||||
$user = User::factory()->create(['account_id' => $account->id]);
|
||||
|
|
|
|||
|
|
@ -9,48 +9,73 @@
|
|||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
test('it dispatches refresh jobs for tokens expiring within 2 hours or already expired', function () {
|
||||
test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () {
|
||||
Queue::fake();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
// Should be refreshed (expires in 1 hour)
|
||||
$expiringSoon = SocialAccount::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(),
|
||||
]);
|
||||
|
||||
// Should NOT be refreshed (expires in 5 hours — outside the proactive window)
|
||||
// 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()->addHours(5),
|
||||
'token_expires_at' => now()->addDays(2),
|
||||
]);
|
||||
|
||||
// SHOULD be refreshed (already expired — last-chance attempt before the
|
||||
// refresh_token also dies at the provider).
|
||||
$justExpired = SocialAccount::factory()->create([
|
||||
// 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(),
|
||||
]);
|
||||
|
||||
// Should NOT be refreshed (disconnected)
|
||||
// Disconnected — never refreshed.
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'platform' => Platform::X,
|
||||
'platform' => Platform::Pinterest,
|
||||
'status' => Status::Disconnected,
|
||||
'token_expires_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
// Should NOT be refreshed (already token expired — daily verify handles these)
|
||||
// Already token expired — daily verify handles these.
|
||||
SocialAccount::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'platform' => Platform::Pinterest,
|
||||
'platform' => Platform::YouTube,
|
||||
'status' => Status::TokenExpired,
|
||||
'token_expires_at' => now()->subHour(),
|
||||
]);
|
||||
|
|
@ -58,9 +83,40 @@
|
|||
$this->artisan('social:refresh-expiring-tokens')
|
||||
->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(RefreshSocialToken::class, 2);
|
||||
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id);
|
||||
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $justExpired->id);
|
||||
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 () {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\ConnectionVerifier;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
|
|
@ -24,22 +26,114 @@
|
|||
]);
|
||||
});
|
||||
|
||||
test('refresh job calls refreshToken (not verify) on the verifier', function () {
|
||||
test('refresh job routes through verify (access-token-first) not refreshToken', function () {
|
||||
$verifier = mock(ConnectionVerifier::class);
|
||||
$verifier->shouldReceive('refreshToken')->once()->with(
|
||||
$verifier->shouldReceive('verify')->once()->with(
|
||||
Mockery::on(fn ($account) => $account->id === $this->account->id)
|
||||
);
|
||||
$verifier->shouldNotReceive('verify');
|
||||
$verifier->shouldNotReceive('refreshToken');
|
||||
app()->instance(ConnectionVerifier::class, $verifier);
|
||||
|
||||
(new RefreshSocialToken($this->account))->handle($verifier);
|
||||
});
|
||||
|
||||
test('proactive refresh does NOT rotate the X refresh token while the access token still works', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
|
||||
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
||||
'access_token' => 'should-not-be-used',
|
||||
'refresh_token' => 'should-not-be-used',
|
||||
'expires_in' => 7200,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
// Token is "expiring soon" (inside the proactive window) but still valid.
|
||||
$this->account->update([
|
||||
'token_expires_at' => now()->addMinutes(20),
|
||||
'refresh_token' => 'original-refresh-token',
|
||||
]);
|
||||
|
||||
(new RefreshSocialToken($this->account))->handle(app(ConnectionVerifier::class));
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me'));
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
|
||||
expect($this->account->fresh()->refresh_token)->toBe('original-refresh-token');
|
||||
expect($this->account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('proactive refresh EXTENDS a still-valid Instagram token (extension-model platform)', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'status' => Status::Connected,
|
||||
'access_token' => 'old-ig-token',
|
||||
'token_expires_at' => now()->addMinutes(20),
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'access_token' => 'extended-ig-token',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
||||
|
||||
// Instagram/Threads extend the token itself and can't refresh once expired,
|
||||
// so a still-valid token IS extended proactively — unlike rotating platforms.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
||||
expect($account->fresh()->access_token)->toBe('extended-ig-token');
|
||||
});
|
||||
|
||||
test('proactive refresh EXTENDS a still-valid Threads token (extension-model platform)', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
'status' => Status::Connected,
|
||||
'access_token' => 'old-threads-token',
|
||||
'token_expires_at' => now()->addMinutes(20),
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'access_token' => 'extended-threads-token',
|
||||
'expires_in' => 5184000,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
||||
expect($account->fresh()->access_token)->toBe('extended-threads-token');
|
||||
});
|
||||
|
||||
test('proactive refresh does NOT disconnect Instagram on a Meta rate-limit (400 OAuthException code 4)', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'status' => Status::Connected,
|
||||
'access_token' => 'valid-ig-token',
|
||||
'token_expires_at' => now()->addMinutes(20),
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
|
||||
], 400),
|
||||
]);
|
||||
|
||||
(new RefreshSocialToken($account))->handle(app(ConnectionVerifier::class));
|
||||
|
||||
// A rate-limit is transient — the still-valid token must stay Connected.
|
||||
expect($account->fresh()->status)->toBe(Status::Connected);
|
||||
expect($account->fresh()->access_token)->toBe('valid-ig-token');
|
||||
});
|
||||
|
||||
test('refresh job marks account as TokenExpired when refresh_token is rejected', function () {
|
||||
Queue::fake();
|
||||
|
||||
$verifier = mock(ConnectionVerifier::class);
|
||||
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
||||
$verifier->shouldReceive('verify')->once()->andThrow(
|
||||
new TokenExpiredException('refresh_token revoked')
|
||||
);
|
||||
app()->instance(ConnectionVerifier::class, $verifier);
|
||||
|
|
@ -61,7 +155,7 @@
|
|||
});
|
||||
|
||||
$verifier = mock(ConnectionVerifier::class);
|
||||
$verifier->shouldReceive('refreshToken')->once()->andThrow(new RuntimeException('network blip'));
|
||||
$verifier->shouldReceive('verify')->once()->andThrow(new RuntimeException('network blip'));
|
||||
app()->instance(ConnectionVerifier::class, $verifier);
|
||||
|
||||
(new RefreshSocialToken($this->account))->handle($verifier);
|
||||
|
|
@ -79,7 +173,7 @@
|
|||
});
|
||||
|
||||
$verifier = mock(ConnectionVerifier::class);
|
||||
$verifier->shouldReceive('refreshToken')->once()->andThrow(
|
||||
$verifier->shouldReceive('verify')->once()->andThrow(
|
||||
new PlatformUnavailableException('X API returned 503 during token refresh', 503)
|
||||
);
|
||||
app()->instance(ConnectionVerifier::class, $verifier);
|
||||
|
|
|
|||
|
|
@ -263,6 +263,22 @@ function subscribeOnboardingAccount(Account $account): void
|
|||
);
|
||||
});
|
||||
|
||||
test('connect passes the workspace plan so the client can fire begin_checkout', function () {
|
||||
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
|
||||
onboardingWorkspace($this->user);
|
||||
|
||||
$plan = Plan::where('slug', Slug::Workspace)->firstOrFail();
|
||||
|
||||
$response = $this->actingAs($this->user->fresh())->get(route('app.onboarding.connect'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->component('onboarding/Connect')
|
||||
->where('plan.name', $plan->name)
|
||||
->where('plan.interval', 'monthly')
|
||||
);
|
||||
});
|
||||
|
||||
test('connect offers a single linkedin card and no standalone linkedin page card', function () {
|
||||
$this->user->update(['persona' => Persona::Agency->value, 'goals' => [Goal::SaveTime->value]]);
|
||||
onboardingWorkspace($this->user);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -283,6 +284,42 @@
|
|||
Http::assertSent(fn ($request) => str_contains($request->url(), 'refresh_access_token'));
|
||||
});
|
||||
|
||||
test('threads refresh records a 60-day expiry when the response omits expires_in', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'access_token' => 'new-threads-token',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->threads()->create([
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
]);
|
||||
|
||||
(new ConnectionVerifier)->refreshToken($account);
|
||||
|
||||
$account->refresh();
|
||||
expect($account->token_expires_at)->not->toBeNull();
|
||||
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
|
||||
});
|
||||
|
||||
test('instagram refresh records a 60-day expiry when the response omits expires_in', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'access_token' => 'new-instagram-token',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->instagram()->create([
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
]);
|
||||
|
||||
(new ConnectionVerifier)->refreshToken($account);
|
||||
|
||||
$account->refresh();
|
||||
expect($account->token_expires_at)->not->toBeNull();
|
||||
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
|
||||
});
|
||||
|
||||
test('does NOT refresh proactively when token still works (lazy refresh)', function () {
|
||||
Http::fake([
|
||||
'api.linkedin.com/*' => Http::response(['sub' => '123'], 200),
|
||||
|
|
@ -414,6 +451,63 @@
|
|||
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('does not disconnect when a concurrent refresh already rotated the token (lost-rotation race)', function () {
|
||||
Http::fake([
|
||||
// Our stale refresh_token is rejected — a concurrent process already used it.
|
||||
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
|
||||
// But the access_token the winning refresh persisted still works.
|
||||
config('trypost.platforms.x.api').'/users/me' => Http::response(['data' => ['id' => '123']], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->x()->create([
|
||||
'status' => Status::Connected,
|
||||
'access_token' => 'stale-token',
|
||||
'refresh_token' => 'already-rotated',
|
||||
'token_expires_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
// Simulate the concurrent refresh: a separate instance persists a fresh,
|
||||
// valid token (through the encrypted cast) while our in-memory copy stays
|
||||
// the stale, expired one.
|
||||
SocialAccount::find($account->id)->update([
|
||||
'access_token' => 'fresh-token',
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
]);
|
||||
|
||||
expect((new ConnectionVerifier)->verify($account))->toBeTrue();
|
||||
expect($account->fresh()->status)->toBe(Status::Connected);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/users/me')
|
||||
&& $request->header('Authorization')[0] === 'Bearer fresh-token');
|
||||
});
|
||||
|
||||
test('does not disconnect when the verify after a refresh 401s once but a fresh token is available', function () {
|
||||
Http::fake([
|
||||
// Refresh succeeds and rotates the token...
|
||||
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
||||
'access_token' => 'refreshed-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 7200,
|
||||
], 200),
|
||||
// ...but the verify that follows 401s once (the sub-commit window where a
|
||||
// lock-skipped refresh reloads a not-yet-persisted token) before
|
||||
// succeeding on the reload + retry.
|
||||
config('trypost.platforms.x.api').'/users/me' => Http::sequence()
|
||||
->push(['error' => 'unauthorized'], 401)
|
||||
->push(['data' => ['id' => '123']], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->x()->create([
|
||||
'status' => Status::Connected,
|
||||
'access_token' => 'stale-token',
|
||||
'refresh_token' => 'old-refresh',
|
||||
'token_expires_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
expect((new ConnectionVerifier)->verify($account))->toBeTrue();
|
||||
expect($account->fresh()->status)->toBe(Status::Connected);
|
||||
});
|
||||
|
||||
test('4xx during refresh keeps raising TokenExpiredException', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
|
||||
|
|
@ -494,3 +588,65 @@
|
|||
|
||||
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
|
||||
});
|
||||
|
||||
test('instagram refresh treats a Meta rate-limit (400 OAuthException code 4) as transient, not a dead token', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->instagram()->create([
|
||||
'token_expires_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
|
||||
->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('threads refresh treats a Meta rate-limit (400 OAuthException code 17) as transient, not a dead token', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17],
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->threads()->create([
|
||||
'token_expires_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
|
||||
->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('instagram refresh treats code 190 as a genuinely expired token', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response([
|
||||
'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190],
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->instagram()->create([
|
||||
'token_expires_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
expect(fn () => (new ConnectionVerifier)->refreshToken($account))
|
||||
->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('instagram verify treats a Meta rate-limit (OAuthException code 4) as still-valid, not a disconnect', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([
|
||||
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
|
||||
], 400),
|
||||
]);
|
||||
|
||||
// Not expired, so verify hits the endpoint directly (no refresh).
|
||||
$account = SocialAccount::factory()->instagram()->create([
|
||||
'token_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
// A rate-limit must NOT raise TokenExpiredException (which would disconnect);
|
||||
// verify returns false and the caller leaves the account connected.
|
||||
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -804,11 +804,12 @@ protected function processingPollSeconds(): int
|
|||
->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('linkedin publisher refreshes the token when it is expiring soon', function () {
|
||||
test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
||||
$this->socialAccount->update([
|
||||
'token_expires_at' => now()->addMinutes(5),
|
||||
'refresh_token' => 'refresh-token-123',
|
||||
]);
|
||||
$originalAccessToken = $this->socialAccount->access_token;
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
||||
|
|
@ -822,8 +823,11 @@ protected function processingPollSeconds(): int
|
|||
$result = $this->publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['id'])->toBe('urn:li:share:soon');
|
||||
|
||||
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
|
||||
$this->socialAccount->refresh();
|
||||
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
||||
});
|
||||
|
||||
test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,31 @@
|
|||
});
|
||||
});
|
||||
|
||||
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
||||
$this->socialAccount->update([
|
||||
'token_expires_at' => now()->addMinutes(5),
|
||||
'refresh_token' => 'original-refresh-token',
|
||||
]);
|
||||
$originalAccessToken = $this->socialAccount->access_token;
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '999']], 200),
|
||||
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
||||
'access_token' => 'should-not-be-used',
|
||||
'refresh_token' => 'should-not-be-used',
|
||||
'expires_in' => 7200,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->publisher->publish($this->postPlatform);
|
||||
|
||||
// X single-use refresh tokens: a still-valid access_token must NOT be rotated.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
|
||||
$this->socialAccount->refresh();
|
||||
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
||||
expect($this->socialAccount->refresh_token)->toBe('original-refresh-token');
|
||||
});
|
||||
|
||||
test('x publisher uses bearer token authentication', function () {
|
||||
Http::fake([
|
||||
'https://api.x.com/2/tweets' => Http::response([
|
||||
|
|
|
|||
|
|
@ -143,11 +143,12 @@
|
|||
->toThrow(TokenExpiredException::class);
|
||||
});
|
||||
|
||||
test('x publisher refreshes token when expiring soon', function () {
|
||||
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
||||
$this->socialAccount->update([
|
||||
'token_expires_at' => now()->addMinutes(5),
|
||||
'refresh_token' => 'refresh-token-123',
|
||||
]);
|
||||
$originalAccessToken = $this->socialAccount->access_token;
|
||||
|
||||
Http::fake([
|
||||
'*/2/oauth2/token' => Http::response([
|
||||
|
|
@ -164,8 +165,11 @@
|
|||
$result = $publisher->publish($this->postPlatform);
|
||||
|
||||
expect($result['id'])->toBe('tweet-123');
|
||||
|
||||
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
|
||||
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/2/oauth2/token'));
|
||||
$this->socialAccount->refresh();
|
||||
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
||||
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
||||
});
|
||||
|
||||
test('x publisher handles 403 error as generic error', function () {
|
||||
|
|
|
|||
|
|
@ -217,3 +217,75 @@
|
|||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('threads callback fails the connect when the long-lived token exchange fails', function () {
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'threads_oauth_state' => $state,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([
|
||||
'access_token' => 'short-lived-token',
|
||||
'user_id' => '123456789',
|
||||
], 200),
|
||||
config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response('upstream error', 503),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
|
||||
'code' => 'test-auth-code',
|
||||
'state' => $state,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false));
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.error_connecting')));
|
||||
|
||||
// A short-lived-only account would silently die within the hour and never be
|
||||
// picked up by the refresh cron, so the connect must not persist one.
|
||||
$this->assertDatabaseMissing('social_accounts', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('threads callback records a 60-day expiry when the long-lived exchange omits expires_in', function () {
|
||||
$state = bin2hex(random_bytes(16));
|
||||
|
||||
session([
|
||||
'social_connect_workspace' => $this->workspace->id,
|
||||
'threads_oauth_state' => $state,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.threads.auth_api').'/oauth/access_token' => Http::response([
|
||||
'access_token' => 'short-lived-token',
|
||||
'user_id' => '123456789',
|
||||
], 200),
|
||||
config('trypost.platforms.threads.auth_api').'/access_token*' => Http::response([
|
||||
'access_token' => 'long-lived-token',
|
||||
], 200),
|
||||
config('trypost.platforms.threads.graph_api').'/123456789*' => Http::response([
|
||||
'id' => '123456789',
|
||||
'username' => 'testuser',
|
||||
'name' => 'Test User',
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [
|
||||
'code' => 'test-auth-code',
|
||||
'state' => $state,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true));
|
||||
|
||||
$account = $this->workspace->socialAccounts()
|
||||
->where('platform', Platform::Threads)
|
||||
->first();
|
||||
|
||||
expect($account->token_expires_at)->not->toBeNull();
|
||||
expect($account->token_expires_at->isAfter(now()->addDays(59)))->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Notification\Type;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Events\NotificationCreated;
|
||||
use App\Jobs\SendNotification;
|
||||
|
|
@ -20,6 +21,33 @@
|
|||
$this->workspace = Workspace::factory()->create(['user_id' => $this->owner->id]);
|
||||
});
|
||||
|
||||
// ---- needsProactiveTokenRefresh ----
|
||||
|
||||
test('needsProactiveTokenRefresh: rotating platforms refresh only when expired, extension platforms while still valid', function () {
|
||||
// Rotating (X): a still-valid token that is merely expiring soon is NOT refreshed.
|
||||
$xValid = SocialAccount::factory()->x()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token_expires_at' => now()->addMinutes(10),
|
||||
]);
|
||||
expect($xValid->needsProactiveTokenRefresh())->toBeFalse();
|
||||
|
||||
// Rotating (X): once actually expired, it is refreshed.
|
||||
$xExpired = SocialAccount::factory()->x()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'token_expires_at' => now()->subMinute(),
|
||||
]);
|
||||
expect($xExpired->needsProactiveTokenRefresh())->toBeTrue();
|
||||
|
||||
// Extension-model (Instagram): a still-valid token that is expiring soon MUST
|
||||
// be refreshed while valid — its token can't be extended once expired.
|
||||
$igValid = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
'token_expires_at' => now()->addMinutes(10),
|
||||
]);
|
||||
expect($igValid->needsProactiveTokenRefresh())->toBeTrue();
|
||||
});
|
||||
|
||||
// ---- markAsTokenExpired ----
|
||||
|
||||
test('markAsTokenExpired updates status and dispatches notification when transitioning from connected', function () {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,24 @@
|
|||
expect(Platform::Pinterest->supportsTextOnly())->toBeFalse();
|
||||
});
|
||||
|
||||
test('platform exposes the correct default token TTL fallback', function () {
|
||||
// X access tokens live 2 hours.
|
||||
expect(Platform::X->defaultTokenTtlSeconds())->toBe(7200);
|
||||
|
||||
// Instagram and Threads use Meta's 60-day long-lived token.
|
||||
expect(Platform::Instagram->defaultTokenTtlSeconds())->toBe(5184000);
|
||||
expect(Platform::Threads->defaultTokenTtlSeconds())->toBe(5184000);
|
||||
|
||||
// Networks that always return expires_in, set a fixed lifetime directly, or
|
||||
// never expire have no fallback here.
|
||||
expect(Platform::LinkedIn->defaultTokenTtlSeconds())->toBeNull();
|
||||
expect(Platform::TikTok->defaultTokenTtlSeconds())->toBeNull();
|
||||
expect(Platform::YouTube->defaultTokenTtlSeconds())->toBeNull();
|
||||
expect(Platform::Pinterest->defaultTokenTtlSeconds())->toBeNull();
|
||||
expect(Platform::Bluesky->defaultTokenTtlSeconds())->toBeNull();
|
||||
expect(Platform::Facebook->defaultTokenTtlSeconds())->toBeNull();
|
||||
});
|
||||
|
||||
test('platform is enabled by default', function () {
|
||||
expect(Platform::LinkedIn->isEnabled())->toBeTrue();
|
||||
expect(Platform::Instagram->isEnabled())->toBeTrue();
|
||||
|
|
|
|||
22
tests/Unit/Services/MediaSearchKeyFallbackTest.php
Normal file
22
tests/Unit/Services/MediaSearchKeyFallbackTest.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\GiphyService;
|
||||
use App\Services\UnsplashService;
|
||||
|
||||
test('unsplash service tolerates a null access key without throwing', function () {
|
||||
config(['services.unsplash.access_key' => null]);
|
||||
|
||||
$result = (new UnsplashService)->search('cats');
|
||||
|
||||
expect($result)->toBe(['results' => [], 'total' => 0, 'total_pages' => 0]);
|
||||
});
|
||||
|
||||
test('giphy service tolerates a null api key without throwing', function () {
|
||||
config(['services.giphy.api_key' => null]);
|
||||
|
||||
$result = (new GiphyService)->search('cats');
|
||||
|
||||
expect($result)->toBe(['results' => [], 'total' => 0, 'total_pages' => 0]);
|
||||
});
|
||||
35
tests/Unit/Services/Social/Meta/GraphErrorTest.php
Normal file
35
tests/Unit/Services/Social/Meta/GraphErrorTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Social\Meta\GraphError;
|
||||
|
||||
test('code 190 indicates a genuinely invalid token', function () {
|
||||
expect(GraphError::indicatesInvalidToken([
|
||||
'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190],
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
test('rate-limit codes carried as OAuthException do NOT indicate an invalid token', function () {
|
||||
// Meta returns rate limits as HTTP 4xx with type OAuthException — they must
|
||||
// stay transient so a throttle never disconnects a still-valid token.
|
||||
expect(GraphError::indicatesInvalidToken([
|
||||
'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4],
|
||||
]))->toBeFalse();
|
||||
|
||||
expect(GraphError::indicatesInvalidToken([
|
||||
'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17],
|
||||
]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('transient codes do NOT indicate an invalid token', function () {
|
||||
expect(GraphError::indicatesInvalidToken([
|
||||
'error' => ['message' => 'Service temporarily unavailable', 'code' => 2],
|
||||
]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a body with no error, or a null body, does not indicate an invalid token', function () {
|
||||
expect(GraphError::indicatesInvalidToken(null))->toBeFalse();
|
||||
expect(GraphError::indicatesInvalidToken([]))->toBeFalse();
|
||||
expect(GraphError::indicatesInvalidToken(['data' => ['id' => '123']]))->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in a new issue