diff --git a/app/Exceptions/Social/BlueskyPublishException.php b/app/Exceptions/Social/BlueskyPublishException.php new file mode 100644 index 00000000..b6fc6b1a --- /dev/null +++ b/app/Exceptions/Social/BlueskyPublishException.php @@ -0,0 +1,77 @@ +status(); + $body = $response->json(); + $rawResponse = $response->body(); + + $error = data_get($body, 'error', ''); + $errorMessage = data_get($body, 'message', 'An unknown Bluesky error occurred.'); + + if (in_array($error, ['ExpiredToken', 'InvalidToken'], true)) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $error, + ); + } + + if (str_contains($rawResponse, 'BlobTooLarge') || $status === 413) { + return new static( + userMessage: "Image exceeds Bluesky's 1MB limit.", + category: ErrorCategory::MediaFormat, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + if ($error === 'InvalidRequest') { + return new static( + userMessage: 'Invalid post data.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: $error, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Bluesky server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $errorMessage, + category: ErrorCategory::Unknown, + platformErrorCode: $error ?: null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'bluesky'; + } +} diff --git a/app/Exceptions/Social/MastodonPublishException.php b/app/Exceptions/Social/MastodonPublishException.php new file mode 100644 index 00000000..782f8ab4 --- /dev/null +++ b/app/Exceptions/Social/MastodonPublishException.php @@ -0,0 +1,94 @@ +status(); + $body = $response->json(); + $rawResponse = $response->body(); + + $errorMessage = data_get($body, 'error', 'An unknown Mastodon error occurred.'); + + if ($status === 401) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: (string) $status, + ); + } + + if ($status === 403) { + return new static( + userMessage: 'This action is not allowed.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 422 && str_contains(strtolower($rawResponse), "text can't be blank")) { + return new static( + userMessage: 'Post text is required when no media is attached.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 422) { + return new static( + userMessage: 'Media validation failed.', + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 413) { + return new static( + userMessage: 'File is too large for this Mastodon instance.', + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 503) { + return new static( + userMessage: 'Mastodon server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $errorMessage, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'mastodon'; + } +} diff --git a/app/Exceptions/Social/PinterestPublishException.php b/app/Exceptions/Social/PinterestPublishException.php new file mode 100644 index 00000000..58ad92c2 --- /dev/null +++ b/app/Exceptions/Social/PinterestPublishException.php @@ -0,0 +1,93 @@ +status(); + $body = $response->json(); + $rawResponse = $response->body(); + + if ($status === 401) { + throw new TokenExpiredException( + message: data_get($body, 'message', 'Access token has expired or been revoked'), + platformErrorCode: (string) $status, + ); + } + + if ($status === 403) { + return new static( + userMessage: 'Not authorized to create pins on this board.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Rate limit exceeded. Please try again later.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 400 && str_contains(strtolower($rawResponse), 'board')) { + return new static( + userMessage: 'Invalid board. Please select a valid board.', + category: ErrorCategory::ContentPolicy, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Pinterest server error. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $rawResponse, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + public static function fromProcessingStatus(string $status, ?string $rawResponse = null): static + { + if ($status === 'failed') { + return new static( + userMessage: 'Media processing failed. Please try a different file.', + category: ErrorCategory::MediaFormat, + platformErrorCode: null, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $rawResponse ?? 'An unknown Pinterest error occurred.', + category: ErrorCategory::Unknown, + platformErrorCode: null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'pinterest'; + } +} diff --git a/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php b/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php new file mode 100644 index 00000000..3aa52c9a --- /dev/null +++ b/tests/Unit/Exceptions/Social/BlueskyPublishExceptionTest.php @@ -0,0 +1,81 @@ + 'ExpiredToken', 'message' => 'Token has expired.'], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + BlueskyPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('InvalidToken error throws TokenExpiredException', function () { + $response = Http::response(['error' => 'InvalidToken', 'message' => 'Token is invalid.'], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + BlueskyPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('BlobTooLarge in body maps to MediaFormat category', function () { + $response = Http::response(['error' => 'BlobTooLarge', 'message' => 'Blob too large.'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + $exception = BlueskyPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::MediaFormat) + ->and($exception->userMessage)->toBe("Image exceeds Bluesky's 1MB limit."); +}); + +test('HTTP 429 maps to RateLimit category', function () { + $response = Http::response(['error' => 'RateLimitExceeded', 'message' => 'Too many requests.'], 429); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + $exception = BlueskyPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Rate limit exceeded. Please try again later.'); +}); + +test('unknown error maps to Unknown category with error message', function () { + $response = Http::response(['error' => 'SomeUnknownError', 'message' => 'Something went wrong.'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + $exception = BlueskyPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('Something went wrong.'); +}); + +test('InvalidRequest error maps to ContentPolicy category', function () { + $response = Http::response(['error' => 'InvalidRequest', 'message' => 'Post data is invalid.'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + $exception = BlueskyPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Invalid post data.'); +}); + +test('HTTP 500 maps to ServerError category', function () { + $response = Http::response(['error' => 'InternalServerError', 'message' => 'Server error.'], 500); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + $exception = BlueskyPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('Bluesky server error. Please try again.'); +}); + +test('platform returns bluesky', function () { + $response = Http::response(['error' => 'SomeError', 'message' => 'Error.'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://bsky.social/xrpc/test'); + + $exception = BlueskyPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('bluesky'); +}); diff --git a/tests/Unit/Exceptions/Social/MastodonPublishExceptionTest.php b/tests/Unit/Exceptions/Social/MastodonPublishExceptionTest.php new file mode 100644 index 00000000..882932e8 --- /dev/null +++ b/tests/Unit/Exceptions/Social/MastodonPublishExceptionTest.php @@ -0,0 +1,84 @@ + 'The access token is invalid'], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + MastodonPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('HTTP 403 maps to Permission category', function () { + $response = Http::response(['error' => 'This action is not allowed'], 403); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('This action is not allowed.'); +}); + +test('HTTP 422 with text can\'t be blank maps to ContentPolicy category', function () { + $response = Http::response(['error' => "text can't be blank"], 422); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Post text is required when no media is attached.'); +}); + +test('HTTP 413 maps to MediaFormat category', function () { + $response = Http::response(['error' => 'File too large'], 413); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::MediaFormat) + ->and($exception->userMessage)->toBe('File is too large for this Mastodon instance.'); +}); + +test('HTTP 429 maps to RateLimit category', function () { + $response = Http::response(['error' => 'Too many requests'], 429); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Rate limit exceeded. Please try again later.'); +}); + +test('HTTP 422 without text blank maps to MediaFormat category', function () { + $response = Http::response(['error' => 'Validation failed'], 422); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::MediaFormat) + ->and($exception->userMessage)->toBe('Media validation failed.'); +}); + +test('unknown status maps to Unknown category with error message', function () { + $response = Http::response(['error' => 'Something went wrong'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('Something went wrong'); +}); + +test('platform returns mastodon', function () { + $response = Http::response(['error' => 'Some error'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://mastodon.social/api/v1/statuses'); + + $exception = MastodonPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('mastodon'); +}); diff --git a/tests/Unit/Exceptions/Social/PinterestPublishExceptionTest.php b/tests/Unit/Exceptions/Social/PinterestPublishExceptionTest.php new file mode 100644 index 00000000..cbfc348c --- /dev/null +++ b/tests/Unit/Exceptions/Social/PinterestPublishExceptionTest.php @@ -0,0 +1,74 @@ + 'Access token expired.'], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://api.pinterest.com/test'); + + PinterestPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('HTTP 429 maps to RateLimit category', function () { + $response = Http::response(['message' => 'Too many requests.'], 429); + $fakeResponse = Http::fake(['*' => $response])->post('https://api.pinterest.com/test'); + + $exception = PinterestPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Rate limit exceeded. Please try again later.'); +}); + +test('HTTP 403 maps to Permission category', function () { + $response = Http::response(['message' => 'Forbidden.'], 403); + $fakeResponse = Http::fake(['*' => $response])->post('https://api.pinterest.com/test'); + + $exception = PinterestPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Not authorized to create pins on this board.'); +}); + +test('HTTP 400 with board in body maps to ContentPolicy category', function () { + $response = Http::response(['message' => 'Invalid board_id provided.'], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://api.pinterest.com/test'); + + $exception = PinterestPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Invalid board. Please select a valid board.'); +}); + +test('processing status failed maps to MediaFormat category', function () { + $exception = PinterestPublishException::fromProcessingStatus('failed'); + + expect($exception->category)->toBe(ErrorCategory::MediaFormat) + ->and($exception->userMessage)->toBe('Media processing failed. Please try a different file.'); +}); + +test('processing status unknown maps to Unknown category', function () { + $exception = PinterestPublishException::fromProcessingStatus('pending', 'raw response'); + + expect($exception->category)->toBe(ErrorCategory::Unknown); +}); + +test('HTTP 500 maps to ServerError category', function () { + $response = Http::response(['message' => 'Internal server error.'], 500); + $fakeResponse = Http::fake(['*' => $response])->post('https://api.pinterest.com/test'); + + $exception = PinterestPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('Pinterest server error. Please try again.'); +}); + +test('platform returns pinterest', function () { + $exception = PinterestPublishException::fromProcessingStatus('unknown'); + + expect($exception->platform())->toBe('pinterest'); +});