diff --git a/CLAUDE.md b/CLAUDE.md index 98fdb636..e7305716 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,6 +349,17 @@ ## External Service URLs - Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes. - Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config. +## Meta (Facebook / Instagram / Threads) API Documentation (official sources) + +When touching OAuth, token refresh, or error classification for Facebook/Instagram/Threads, consult these first — do not guess error codes or rate-limit behavior from memory. All three share the Graph API error format (`error.code`, `error.type`). + +- General error handling / codes 1, 2, 4, 17, 190: https://developers.facebook.com/docs/graph-api/guides/error-handling/ +- Rate limiting — Platform Rate Limits (app/user tokens, codes 4/17) vs. Business Use Case (BUC) Rate Limits (Page/system-user tokens, codes 80000–80014 — e.g. `80001` Pages API, `80002` Instagram Platform; BUC rejections come back as plain HTTP 400, not 429): https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ +- Instagram content-publishing error codes: https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/error-codes/ +- Threads API: https://developers.facebook.com/docs/threads — reuses the Graph API error format; no separate Threads-specific error code table exists. +- Our `App\Services\Social\Meta\GraphError` (used by `ConnectionVerifier`'s verify/refresh calls) has the full rationale and code table in its class docblock — check there before changing transient-vs-confirmed-rejection classification. +- `Facebook`/`InstagramFacebook` `SocialAccount`s use a Facebook Page access token (BUC-limited); `Instagram` (direct login) and `Threads` use a user access token (Platform Rate Limit-limited). This affects which rate-limit codes apply to which platform. + ## TryPost.it Documentation - All our documentation to final user it's under https://docs.trypost.it diff --git a/app/Exceptions/Social/FacebookPublishException.php b/app/Exceptions/Social/FacebookPublishException.php index e362bfd6..ec277c4a 100644 --- a/app/Exceptions/Social/FacebookPublishException.php +++ b/app/Exceptions/Social/FacebookPublishException.php @@ -15,7 +15,6 @@ public static function fromApiResponse(mixed $response): static $body = $response->json(); $rawResponse = $response->body(); - $errorType = data_get($body, 'error.type'); $errorCode = data_get($body, 'error.code'); $errorSubcode = data_get($body, 'error.error_subcode'); $errorMessage = data_get($body, 'error.message', 'An unknown Facebook error occurred.'); diff --git a/app/Exceptions/Social/InstagramPublishException.php b/app/Exceptions/Social/InstagramPublishException.php index 4b836d46..d1c7678b 100644 --- a/app/Exceptions/Social/InstagramPublishException.php +++ b/app/Exceptions/Social/InstagramPublishException.php @@ -15,7 +15,6 @@ public static function fromApiResponse(mixed $response): static $body = $response->json(); $rawResponse = $response->body(); - $errorType = data_get($body, 'error.type'); $errorCode = data_get($body, 'error.code'); $errorSubcode = data_get($body, 'error.error_subcode'); $errorUserMsg = data_get($body, 'error.error_user_msg'); diff --git a/app/Exceptions/Social/ThreadsPublishException.php b/app/Exceptions/Social/ThreadsPublishException.php index 7ab5f07a..bcf0bf71 100644 --- a/app/Exceptions/Social/ThreadsPublishException.php +++ b/app/Exceptions/Social/ThreadsPublishException.php @@ -16,7 +16,6 @@ public static function fromApiResponse(mixed $response): static $rawResponse = $response->body(); $statusCode = $response->status(); - $errorType = data_get($body, 'error.type'); $errorCode = data_get($body, 'error.code'); $errorMessage = data_get($body, 'error.message', 'An unknown Threads error occurred.'); diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 071d554e..2ae7570f 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -319,7 +319,7 @@ private function refreshThreadsToken(SocialAccount $account): void 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, ]), - GraphError::indicatesInvalidToken(...), + fn (?array $body) => ! GraphError::isTransient($body), ); $data = $response->json(); @@ -341,7 +341,7 @@ private function refreshInstagramToken(SocialAccount $account): void 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, ]), - GraphError::indicatesInvalidToken(...), + fn (?array $body) => ! GraphError::isTransient($body), ); $data = $response->json(); @@ -415,13 +415,11 @@ private function verifyInstagram(SocialAccount $account): bool 'access_token' => $account->access_token, ]); - $body = $response->json() ?? []; - - if (GraphError::indicatesInvalidToken($body)) { - throw new TokenExpiredException('Instagram access token is invalid or expired'); + if ($response->successful()) { + return true; } - return $response->successful(); + throw GraphError::classifyVerifyFailure($response, 'Instagram'); } private function verifyFacebook(SocialAccount $account): bool @@ -431,13 +429,11 @@ private function verifyFacebook(SocialAccount $account): bool 'access_token' => $account->access_token, ]); - $body = $response->json() ?? []; - - if (GraphError::indicatesInvalidToken($body)) { - throw new TokenExpiredException('Facebook access token is invalid or expired'); + if ($response->successful()) { + return true; } - return $response->successful(); + throw GraphError::classifyVerifyFailure($response, 'Facebook'); } private function verifyThreads(SocialAccount $account): bool @@ -447,13 +443,11 @@ private function verifyThreads(SocialAccount $account): bool 'access_token' => $account->access_token, ]); - $body = $response->json() ?? []; - - if (GraphError::indicatesInvalidToken($body)) { - throw new TokenExpiredException('Threads access token is invalid or expired'); + if ($response->successful()) { + return true; } - return $response->successful(); + throw GraphError::classifyVerifyFailure($response, 'Threads'); } private function verifyTikTok(SocialAccount $account): bool diff --git a/app/Services/Social/Meta/GraphError.php b/app/Services/Social/Meta/GraphError.php index e0097e5b..f81084dd 100644 --- a/app/Services/Social/Meta/GraphError.php +++ b/app/Services/Social/Meta/GraphError.php @@ -4,27 +4,94 @@ namespace App\Services\Social\Meta; +use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\TokenExpiredException; +use Illuminate\Http\Client\Response; + /** - * Interprets Meta Graph API (Facebook / Instagram / Threads) error responses. + * Interprets Meta Graph API (Facebook / Instagram / Threads) error responses + * for the connection-health path (ConnectionVerifier's verify/refresh calls + * against `/me` and `/refresh_access_token`). Does not cover the much larger, + * platform-specific content-publishing error maps in + * FacebookPublishException/InstagramPublishException/ThreadsPublishException. * * 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. + * type "OAuthException", so neither the HTTP status nor the error type alone + * can tell a dead token from a throttle. Two independent rate-limit systems + * exist and both must be treated as transient: + * + * - Platform Rate Limits (app/user access tokens — Instagram and Threads + * accounts in this app): code 4 "app rate limit", code 17 "user rate + * limit". https://developers.facebook.com/docs/graph-api/guides/error-handling/ + * - Business Use Case (BUC) Rate Limits (Page/system-user tokens — Facebook + * and InstagramFacebook accounts here use Page tokens): code 80001 "Pages + * API", code 80002 "Instagram Platform". Unlike Platform Rate Limits, BUC + * rejections come back as an ordinary HTTP 400, not 429. BUC also covers + * several other Meta products (Marketing API, WhatsApp, Messenger, ...) + * with their own 80000-series codes — irrelevant here since this app never + * calls those APIs; add a code only once we actually call the surface it + * belongs to, verified against the table below, not guessed. + * https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ + * - Generic transient upstream problems: code 1, code 2. + * + * Only a known transient code (or a body Meta didn't return as parseable + * JSON at all — a WAF block page, a truncated response, a gateway hiccup — + * which carries no confirmed rejection either) means the failure isn't a + * confirmed rejection. Every other 4xx — including error codes other than + * 190, which Meta also uses to signal a dead token (e.g. code 100 seen on a + * genuinely revoked Threads token) — means the account needs to be + * reconnected. */ 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. + * Codes Meta uses for rate-limit and other transient upstream problems. + * These must never disconnect a still-valid token. + */ + private const TRANSIENT_CODES = [1, 2, 4, 17, 80001, 80002]; + + /** + * Whether the given Meta Graph error body is a known rate-limit or + * transient upstream problem, as opposed to a confirmed rejection + * (dead token, bad request, permission denied, etc.). A body that + * doesn't parse as JSON is treated as transient too — we have no + * confirmed rejection from Meta to act on. * * @param array|null $body */ - public static function indicatesInvalidToken(?array $body): bool + public static function isTransient(?array $body): bool { - return data_get($body, 'error.code') === 190; + return $body === null || in_array(data_get($body, 'error.code'), self::TRANSIENT_CODES, true); + } + + /** + * Whether a failed Meta Graph API response — status and body together — + * represents a transient problem that must not disconnect a still-valid + * token, as opposed to a confirmed rejection. + */ + public static function isTransientFailure(Response $response): bool + { + return $response->serverError() + || $response->status() === 429 + || self::isTransient($response->json()); + } + + /** + * Classify a failed Meta Graph "/me" verify call into the exception the + * caller should throw: a rate-limit or other transient upstream problem + * must not disconnect a still-valid token, but every other rejection — + * including error codes other than 190, which Meta also uses to signal a + * dead token — means the account genuinely needs to be reconnected. + */ + public static function classifyVerifyFailure(Response $response, string $label): PlatformUnavailableException|TokenExpiredException + { + if (self::isTransientFailure($response)) { + return new PlatformUnavailableException( + "{$label} API returned {$response->status()} during verification", + $response->status(), + ); + } + + return new TokenExpiredException("{$label} access token is invalid or expired"); } } diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 9d523ec5..d70734e2 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -634,7 +634,7 @@ ->toThrow(TokenExpiredException::class); }); -test('instagram verify treats a Meta rate-limit (OAuthException code 4) as still-valid, not a disconnect', function () { +test('instagram verify treats a Meta rate-limit (OAuthException code 4) as transient, 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], @@ -647,6 +647,232 @@ ]); // 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(); + // it must be surfaced as PlatformUnavailableException so the caller retries + // next cycle instead of silently — and permanently — treating it as valid. + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('threads verify treats a dead token reported under a non-190 code as genuinely expired', function () { + // Meta doesn't always report a dead Threads token as code 190 — this + // reproduces the "(#100) The requested resource does not exist" case + // from issue #230, which the old code === 190-only check let through + // as a silent, un-flagged "still valid". + Http::fake([ + config('trypost.platforms.threads.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100], + ], 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('threads verify treats a 5xx as platform unavailable, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.threads.graph_api').'/me*' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('facebook verify treats a dead token reported under a non-190 code as genuinely expired', function () { + Http::fake([ + config('trypost.platforms.facebook.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100], + ], 400), + ]); + + $account = SocialAccount::factory()->facebook()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('facebook verify treats a Meta rate-limit as transient, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.facebook.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'Application request limit reached', 'type' => 'OAuthException', 'code' => 4], + ], 400), + ]); + + $account = SocialAccount::factory()->facebook()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('facebook verify treats a Business Use Case rate-limit (Page token, code 80001) as transient, not a disconnect', function () { + // Facebook and InstagramFacebook accounts use Page tokens, which are + // throttled by BUC limits (code 80001) rather than Platform Rate Limits + // (codes 4/17) — and BUC rejections come back as a plain 400, not 429. + Http::fake([ + config('trypost.platforms.facebook.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'There have been too many calls to this Page account.', 'code' => 80001], + ], 400), + ]); + + $account = SocialAccount::factory()->facebook()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('instagram verify treats a Business Use Case rate-limit (code 80002) as transient, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'Instagram Platform rate limit reached.', 'code' => 80002], + ], 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('facebook verify treats a 5xx as platform unavailable, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.facebook.graph_api').'/me*' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->facebook()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('facebook verify treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () { + Http::fake([ + config('trypost.platforms.facebook.graph_api').'/me*' => Http::response('blocked', 400), + ]); + + $account = SocialAccount::factory()->facebook()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('instagram verify treats a dead token reported under a non-190 code as genuinely expired', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/me*' => Http::response([ + 'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100], + ], 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('instagram verify treats a 5xx as platform unavailable, not a disconnect', function () { + Http::fake([ + config('trypost.platforms.instagram.graph_api').'/me*' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('threads refresh treats a dead token reported under a non-190 code as genuinely expired', function () { + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response([ + 'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100], + ], 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('threads verify treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () { + // A WAF block page, truncated response, or gateway hiccup can return a + // 4xx with a body that isn't parseable JSON. There's no confirmed + // rejection from Meta in that case, so it must not disconnect the account. + Http::fake([ + config('trypost.platforms.threads.graph_api').'/me*' => Http::response('blocked', 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('threads verify treats a failed response with a valid but unrecognized JSON shape as a confirmed rejection', function () { + // Unlike an unparseable body, a body that DID parse but has no "error" + // key at all is a real response from Meta, just not shaped like its + // usual error object. This is deliberately NOT treated as transient — + // an unrecognized 4xx shape still disconnects rather than being + // silently ignored, which is the exact bug this class replaced. + Http::fake([ + config('trypost.platforms.threads.graph_api').'/me*' => Http::response(['data' => ['id' => '123']], 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class); +}); + +test('threads refresh treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () { + Http::fake([ + config('trypost.platforms.threads.auth_api').'/refresh_access_token*' => Http::response('blocked', 400), + ]); + + $account = SocialAccount::factory()->threads()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(PlatformUnavailableException::class); +}); + +test('instagram refresh treats a non-JSON failure body as platform unavailable, not a confirmed dead token', function () { + Http::fake([ + config('trypost.platforms.instagram.auth_api').'/refresh_access_token*' => Http::response('blocked', 400), + ]); + + $account = SocialAccount::factory()->instagram()->create([ + 'token_expires_at' => now()->subHour(), + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(PlatformUnavailableException::class); }); diff --git a/tests/Unit/Services/Social/Meta/GraphErrorTest.php b/tests/Unit/Services/Social/Meta/GraphErrorTest.php index f1d79931..c9beb617 100644 --- a/tests/Unit/Services/Social/Meta/GraphErrorTest.php +++ b/tests/Unit/Services/Social/Meta/GraphErrorTest.php @@ -2,34 +2,86 @@ declare(strict_types=1); +use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\TokenExpiredException; use App\Services\Social\Meta\GraphError; +use Illuminate\Support\Facades\Http; -test('code 190 indicates a genuinely invalid token', function () { - expect(GraphError::indicatesInvalidToken([ - 'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190], +test('rate-limit and transient codes are transient', function () { + foreach ([1, 2, 4, 17] as $code) { + expect(GraphError::isTransient([ + 'error' => ['message' => 'temporary problem', 'type' => 'OAuthException', 'code' => $code], + ]))->toBeTrue(); + } +}); + +test('Business Use Case (BUC) rate-limit codes are transient', function () { + // Page/system-user tokens (our Facebook and InstagramFacebook accounts) + // and Instagram Platform are throttled by a separate rate-limit system + // from Platform Rate Limits (codes 4/17), with its own codes. + // https://developers.facebook.com/docs/graph-api/overview/rate-limiting/ + expect(GraphError::isTransient([ + 'error' => ['message' => 'There have been too many calls to this Page account.', 'code' => 80001], + ]))->toBeTrue(); + + expect(GraphError::isTransient([ + 'error' => ['message' => 'Instagram Platform rate limit reached.', 'code' => 80002], ]))->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], +test('code 190 and other confirmed rejections are not transient', function () { + expect(GraphError::isTransient([ + 'error' => ['message' => 'Access token has expired', 'type' => 'OAuthException', 'code' => 190], ]))->toBeFalse(); - expect(GraphError::indicatesInvalidToken([ - 'error' => ['message' => 'User request limit reached', 'type' => 'OAuthException', 'code' => 17], + expect(GraphError::isTransient([ + 'error' => ['message' => 'The requested resource does not exist', 'type' => 'OAuthException', 'code' => 100], ]))->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 is not transient, but a null (unparseable) body is', function () { + // A body Meta didn't return as parseable JSON (WAF block page, truncated + // response, gateway hiccup) carries no confirmed rejection — treat it as + // transient rather than assuming the token is dead. A body that DID parse + // but has no "error" key is a different case: it's a real response from + // the server we called, just not shaped like Meta's usual error object — + // intentionally NOT treated as transient, so an unrecognized 4xx shape + // still results in a confirmed rejection rather than being silently + // ignored (the exact bug this class was rewritten to close). + expect(GraphError::isTransient(null))->toBeTrue(); + expect(GraphError::isTransient([]))->toBeFalse(); + expect(GraphError::isTransient(['data' => ['id' => '123']]))->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(); +test('isTransientFailure treats 5xx and 429 as transient regardless of body', function () { + Http::fake(['example.com/*' => Http::response('upstream timeout', 503)]); + expect(GraphError::isTransientFailure(Http::get('https://example.com/me')))->toBeTrue(); + + Http::fake(['example.com/*' => Http::response(['error' => ['code' => 190]], 429)]); + expect(GraphError::isTransientFailure(Http::get('https://example.com/me')))->toBeTrue(); +}); + +test('isTransientFailure classifies a confirmed 4xx rejection as not transient', function () { + Http::fake(['example.com/*' => Http::response(['error' => ['code' => 190]], 400)]); + expect(GraphError::isTransientFailure(Http::get('https://example.com/me')))->toBeFalse(); +}); + +test('classifyVerifyFailure returns PlatformUnavailableException for a transient failure', function () { + Http::fake(['example.com/*' => Http::response('upstream timeout', 503)]); + + expect(GraphError::classifyVerifyFailure(Http::get('https://example.com/me'), 'Threads')) + ->toBeInstanceOf(PlatformUnavailableException::class); +}); + +test('classifyVerifyFailure returns TokenExpiredException for a confirmed rejection', function () { + Http::fake([ + 'example.com/*' => Http::response([ + 'error' => ['message' => 'The requested resource does not exist', 'code' => 100], + ], 400), + ]); + + $exception = GraphError::classifyVerifyFailure(Http::get('https://example.com/me'), 'Threads'); + + expect($exception)->toBeInstanceOf(TokenExpiredException::class) + ->and($exception->getMessage())->toBe('Threads access token is invalid or expired'); });