diff --git a/app/Exceptions/Social/LinkedInPublishException.php b/app/Exceptions/Social/LinkedInPublishException.php new file mode 100644 index 00000000..aad4a088 --- /dev/null +++ b/app/Exceptions/Social/LinkedInPublishException.php @@ -0,0 +1,64 @@ +json(); + $rawResponse = $response->body(); + $statusCode = $response->status(); + + $errorMessage = data_get($body, 'message', $rawResponse); + + if ($statusCode === 401) { + throw new TokenExpiredException( + message: $errorMessage ?? 'Access token has expired or been revoked', + platformErrorCode: (string) $statusCode, + ); + } + + if (str_contains((string) $rawResponse, 'Unable to obtain activity')) { + return new static( + userMessage: 'LinkedIn server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + if (str_contains((string) $rawResponse, 'resource is forbidden')) { + return new static( + userMessage: 'Not authorized to post to this account.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + [$message, $category] = match ($statusCode) { + 403 => ['Not authorized to post to this account.', ErrorCategory::Permission], + 422 => ['Invalid post data. Please check your content.', ErrorCategory::ContentPolicy], + default => [$errorMessage ?? 'An unknown LinkedIn error occurred.', ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'linkedin'; + } +} diff --git a/app/Exceptions/Social/ThreadsPublishException.php b/app/Exceptions/Social/ThreadsPublishException.php new file mode 100644 index 00000000..1a2026f2 --- /dev/null +++ b/app/Exceptions/Social/ThreadsPublishException.php @@ -0,0 +1,69 @@ +json(); + $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.'); + + if ($errorType === 'OAuthException' || $errorCode === 190) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + ); + } + + if (stripos((string) $rawResponse, "text can't be blank") !== false) { + return new static( + userMessage: 'Post text is required.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + rawResponse: $rawResponse, + ); + } + + if ($statusCode === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + if ($statusCode >= 500) { + return new static( + userMessage: 'Threads server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $errorMessage, + category: ErrorCategory::Unknown, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'threads'; + } +} diff --git a/app/Exceptions/Social/XPublishException.php b/app/Exceptions/Social/XPublishException.php new file mode 100644 index 00000000..97f8dd18 --- /dev/null +++ b/app/Exceptions/Social/XPublishException.php @@ -0,0 +1,81 @@ +json(); + $rawResponse = $response->body(); + $statusCode = $response->status(); + + $type = data_get($body, 'type', ''); + $title = data_get($body, 'title', ''); + $detail = data_get($body, 'detail', $title); + + $typeSuffix = $type !== '' ? basename((string) $type) : ''; + + if ($statusCode === 401 || str_contains((string) $type, 'unsupported-authentication')) { + throw new TokenExpiredException( + message: $detail ?: 'Access token has expired or been revoked', + platformErrorCode: $typeSuffix ?: (string) $statusCode, + ); + } + + if (str_contains((string) $rawResponse, 'invalid URL')) { + return new static( + userMessage: 'Post contains an invalid URL.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + if (str_contains((string) $rawResponse, 'video longer than 2 minutes')) { + return new static( + userMessage: 'Video exceeds the 2-minute limit.', + category: ErrorCategory::MediaFormat, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + if (in_array($statusCode, [500, 502, 503, 504], true)) { + return new static( + userMessage: 'X server error. Please try again later.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $statusCode, + rawResponse: $rawResponse, + ); + } + + [$message, $category] = match ($typeSuffix) { + 'usage-capped' => ['Usage limit exceeded. Please try again later.', ErrorCategory::RateLimit], + 'rate-limit-exceeded' => ['Rate limit exceeded. Please try again later.', ErrorCategory::RateLimit], + 'invalid-request' => ['Invalid request. Check your post content.', ErrorCategory::ContentPolicy], + 'client-forbidden' => ['App not enrolled or lacks required access.', ErrorCategory::Permission], + 'not-authorized-for-resource' => ['Not authorized for this resource.', ErrorCategory::Permission], + 'resource-not-found' => ['Resource not found.', ErrorCategory::ContentPolicy], + default => [$detail ?: $title ?: 'An unknown X error occurred.', ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'x'; + } +} diff --git a/tests/Unit/Exceptions/Social/LinkedInPublishExceptionTest.php b/tests/Unit/Exceptions/Social/LinkedInPublishExceptionTest.php new file mode 100644 index 00000000..26d9f7a4 --- /dev/null +++ b/tests/Unit/Exceptions/Social/LinkedInPublishExceptionTest.php @@ -0,0 +1,83 @@ + 'Unauthorized'], 401); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + LinkedInPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('HTTP 403 maps to Permission category', function () { + $response = Http::response(['message' => 'Forbidden'], 403); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + $exception = LinkedInPublishException::fromApiResponse($fakeResponse); + + expect($exception) + ->toBeInstanceOf(LinkedInPublishException::class) + ->and($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Not authorized to post to this account.'); +}); + +test('body containing "Unable to obtain activity" maps to ServerError category', function () { + $response = Http::response(['message' => 'Unable to obtain activity for URN'], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + $exception = LinkedInPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('LinkedIn server error. Please try again.'); +}); + +test('unknown error maps to Unknown category with response body as message', function () { + $response = Http::response(['message' => 'Something went wrong.'], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + $exception = LinkedInPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('Something went wrong.'); +}); + +test('HTTP 422 maps to ContentPolicy category', function () { + $response = Http::response(['message' => 'Validation error'], 422); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + $exception = LinkedInPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Invalid post data. Please check your content.'); +}); + +test('body containing "resource is forbidden" maps to Permission category', function () { + $response = Http::response(['message' => 'The resource is forbidden'], 403); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + $exception = LinkedInPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Not authorized to post to this account.'); +}); + +test('platform returns linkedin', function () { + $response = Http::response(['message' => 'Some error'], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.linkedin.com/test'); + + $exception = LinkedInPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('linkedin'); +}); diff --git a/tests/Unit/Exceptions/Social/ThreadsPublishExceptionTest.php b/tests/Unit/Exceptions/Social/ThreadsPublishExceptionTest.php new file mode 100644 index 00000000..80d6ab3a --- /dev/null +++ b/tests/Unit/Exceptions/Social/ThreadsPublishExceptionTest.php @@ -0,0 +1,138 @@ + [ + 'message' => 'Invalid OAuth access token.', + 'type' => 'OAuthException', + 'code' => 190, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + ThreadsPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('error code 190 throws TokenExpiredException', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Access token expired.', + 'type' => 'ThreadsException', + 'code' => 190, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + ThreadsPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('HTTP 429 maps to RateLimit category', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Application request limit reached.', + 'type' => 'ThreadsException', + 'code' => 4, + ], + ], 429); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + $exception = ThreadsPublishException::fromApiResponse($fakeResponse); + + expect($exception) + ->toBeInstanceOf(ThreadsPublishException::class) + ->and($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Rate limit exceeded. Please try again later.'); +}); + +test('body containing "text can\'t be blank" maps to ContentPolicy category', function () { + $response = Http::response([ + 'error' => [ + 'message' => "text can't be blank", + 'type' => 'ThreadsException', + 'code' => 100, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + $exception = ThreadsPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Post text is required.'); +}); + +test('"text can\'t be blank" check is case insensitive', function () { + $response = Http::response([ + 'error' => [ + 'message' => "Text Can't Be Blank", + 'type' => 'ThreadsException', + 'code' => 100, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + $exception = ThreadsPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy); +}); + +test('unknown error maps to Unknown category with error message', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'An unexpected error occurred.', + 'type' => 'ThreadsException', + 'code' => 9999, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + $exception = ThreadsPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('An unexpected error occurred.'); +}); + +test('HTTP 500 maps to ServerError category', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Internal server error.', + 'type' => 'ThreadsException', + 'code' => 1, + ], + ], 500); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + $exception = ThreadsPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('Threads server error. Please try again.'); +}); + +test('platform returns threads', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Some error.', + 'type' => 'ThreadsException', + 'code' => 9999, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + $exception = ThreadsPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('threads'); +}); diff --git a/tests/Unit/Exceptions/Social/XPublishExceptionTest.php b/tests/Unit/Exceptions/Social/XPublishExceptionTest.php new file mode 100644 index 00000000..2001af07 --- /dev/null +++ b/tests/Unit/Exceptions/Social/XPublishExceptionTest.php @@ -0,0 +1,137 @@ + 'https://api.x.com/2/problems/not-authorized-for-resource', + 'title' => 'Unauthorized', + 'detail' => 'Could not authenticate you.', + ], 401); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + XPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('type unsupported-authentication throws TokenExpiredException', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/unsupported-authentication', + 'title' => 'Unsupported Authentication', + 'detail' => 'Authentication method not supported.', + ], 403); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + XPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('type usage-capped maps to RateLimit category', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/usage-capped', + 'title' => 'Usage Capped', + 'detail' => 'You have exceeded your monthly usage cap.', + ], 403); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception) + ->toBeInstanceOf(XPublishException::class) + ->and($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Usage limit exceeded. Please try again later.'); +}); + +test('type client-forbidden maps to Permission category', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/client-forbidden', + 'title' => 'Client Forbidden', + 'detail' => 'This app is not enrolled.', + ], 403); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('App not enrolled or lacks required access.'); +}); + +test('HTTP 500 maps to ServerError category', function () { + $response = Http::response([ + 'title' => 'Internal Server Error', + 'detail' => 'Something went wrong on our end.', + ], 500); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('X server error. Please try again later.'); +}); + +test('unknown type maps to Unknown category with detail as message', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/some-unknown-problem', + 'title' => 'Some Unknown Problem', + 'detail' => 'An unknown issue occurred.', + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('An unknown issue occurred.'); +}); + +test('body containing "invalid URL" maps to ContentPolicy category', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/invalid-request', + 'title' => 'Invalid Request', + 'detail' => 'The post contains an invalid URL in the content.', + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Post contains an invalid URL.'); +}); + +test('body containing "video longer than 2 minutes" maps to MediaFormat category', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/invalid-request', + 'title' => 'Invalid Request', + 'detail' => 'The video longer than 2 minutes cannot be uploaded.', + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::MediaFormat) + ->and($exception->userMessage)->toBe('Video exceeds the 2-minute limit.'); +}); + +test('platform returns x', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/some-unknown-problem', + 'title' => 'Error', + 'detail' => 'Some detail.', + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test'); + + $exception = XPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('x'); +});