diff --git a/app/Exceptions/Social/InstagramPublishException.php b/app/Exceptions/Social/InstagramPublishException.php new file mode 100644 index 00000000..c0bbf662 --- /dev/null +++ b/app/Exceptions/Social/InstagramPublishException.php @@ -0,0 +1,72 @@ +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'); + $errorMessage = data_get($body, 'error.message', 'An unknown Instagram error occurred.'); + + if ($errorType === 'OAuthException' || $errorCode === 190) { + throw new TokenExpiredException( + message: $errorMessage, + platformErrorCode: $errorCode !== null ? (string) $errorCode : null, + ); + } + + [$message, $category] = match ($errorSubcode) { + 2207026 => ['Unsupported video format. Please upload MP4 or MOV.', ErrorCategory::MediaFormat], + 2207005 => ['Unsupported image format.', ErrorCategory::MediaFormat], + 2207004 => ['Image is too large (max 8MB).', ErrorCategory::MediaFormat], + 2207009 => ['Aspect ratio not supported (must be between 4:5 and 1.91:1).', ErrorCategory::MediaFormat], + 2207057 => ['Thumbnail offset is outside the video duration.', ErrorCategory::MediaFormat], + 2207023 => ['Unknown media type.', ErrorCategory::MediaFormat], + 2207003 => ['Media download timed out. Please try again.', ErrorCategory::ServerError], + 2207020 => ['Media has expired. Please upload again.', ErrorCategory::ServerError], + 2207032 => ['Failed to create media. Please try again.', ErrorCategory::ServerError], + 2207053 => ['Unknown upload error. Please try again.', ErrorCategory::ServerError], + 2207052 => ['Could not fetch media from URL. Please try again.', ErrorCategory::ServerError], + 2207006 => ['Media not found. Please upload again.', ErrorCategory::ServerError], + 2207008 => ['Media container expired. Please try again in a few minutes.', ErrorCategory::ServerError], + 2207027 => ['Media is not ready for publishing. Please wait and try again.', ErrorCategory::ServerError], + 2207001 => ['Instagram server error. Please try again.', ErrorCategory::ServerError], + 2207010 => ['Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions).', ErrorCategory::ContentPolicy], + 2207028 => ['Carousel needs between 2 and 10 photos/videos.', ErrorCategory::ContentPolicy], + 2207051 => ['Instagram restricted this action to protect the community.', ErrorCategory::ContentPolicy], + 2207035 => ['Product tag positions are not supported for videos.', ErrorCategory::ContentPolicy], + 2207036 => ['Product tag positions are required for photos.', ErrorCategory::ContentPolicy], + 2207037 => ['Invalid product tag. The product may be deleted or not permitted.', ErrorCategory::ContentPolicy], + 2207040 => ['Too many tags (max 20).', ErrorCategory::ContentPolicy], + 2207042 => ['Daily publishing limit reached. Please try again tomorrow.', ErrorCategory::RateLimit], + 2207050 => ['Instagram account is restricted or inactive. Please check the Instagram app.', ErrorCategory::Permission], + 2207081 => ["This account doesn't support Trial Reels.", ErrorCategory::Permission], + default => [$errorUserMsg ?? $errorMessage, ErrorCategory::Unknown], + }; + + return new static( + userMessage: $message, + category: $category, + platformErrorCode: $errorSubcode !== null ? (string) $errorSubcode : null, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'instagram'; + } +} diff --git a/tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php b/tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php new file mode 100644 index 00000000..771bfae0 --- /dev/null +++ b/tests/Unit/Exceptions/Social/InstagramPublishExceptionTest.php @@ -0,0 +1,185 @@ + [ + 'message' => 'Some API message', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 2207026, + 'is_transient' => false, + 'error_user_msg' => 'Video format not supported.', + 'fbtrace_id' => 'abc123', + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception) + ->toBeInstanceOf(InstagramPublishException::class) + ->and($exception->userMessage)->toBe('Unsupported video format. Please upload MP4 or MOV.') + ->and($exception->category)->toBe(ErrorCategory::MediaFormat); +}); + +test('known subcode 2207042 maps to RateLimit category', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Rate limit hit', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 2207042, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Daily publishing limit reached. Please try again tomorrow.'); +}); + +test('known subcode 2207050 maps to Permission category', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Account restricted', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 2207050, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Instagram account is restricted or inactive. Please check the Instagram app.'); +}); + +test('known subcode 2207010 maps to ContentPolicy category', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Caption too long', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 2207010, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ContentPolicy) + ->and($exception->userMessage)->toBe('Caption is too long (max 2,200 characters, 30 hashtags, 20 @mentions).'); +}); + +test('OAuthException type throws TokenExpiredException', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Invalid OAuth access token', + 'type' => 'OAuthException', + 'code' => 400, + 'error_subcode' => 463, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + InstagramPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('error code 190 throws TokenExpiredException', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Invalid access token', + 'type' => 'IGApiException', + 'code' => 190, + 'error_subcode' => 460, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + InstagramPublishException::fromApiResponse($fakeResponse); +})->throws(TokenExpiredException::class); + +test('unknown subcode with error_user_msg uses that message', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Generic API message', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 9999999, + 'error_user_msg' => 'A friendly user-facing message.', + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->userMessage)->toBe('A friendly user-facing message.') + ->and($exception->category)->toBe(ErrorCategory::Unknown); +}); + +test('unknown subcode without error_user_msg falls through to error message', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Some generic API error occurred.', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 9999999, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->userMessage)->toBe('Some generic API error occurred.'); +}); + +test('platformErrorCode is set to the subcode string', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Some error', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 2207026, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->platformErrorCode)->toBe('2207026'); +}); + +test('platform returns instagram', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Some error', + 'type' => 'IGApiException', + 'code' => 36000, + 'error_subcode' => 2207001, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.instagram.com/test'); + + $exception = InstagramPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('instagram'); +});