feat: add Pinterest, Bluesky, Mastodon publish exceptions

This commit is contained in:
Paulo Castellano 2026-03-31 20:43:13 -03:00
parent 6587c76bc2
commit 9999335da6
6 changed files with 503 additions and 0 deletions

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class BlueskyPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->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';
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class MastodonPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->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';
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;
class PinterestPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->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';
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\TokenExpiredException;
use Illuminate\Support\Facades\Http;
test('ExpiredToken error throws TokenExpiredException', function () {
$response = Http::response(['error' => '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');
});

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\MastodonPublishException;
use App\Exceptions\TokenExpiredException;
use Illuminate\Support\Facades\Http;
test('HTTP 401 throws TokenExpiredException', function () {
$response = Http::response(['error' => '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');
});

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\PinterestPublishException;
use App\Exceptions\TokenExpiredException;
use Illuminate\Support\Facades\Http;
test('HTTP 401 throws TokenExpiredException', function () {
$response = Http::response(['message' => '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');
});