From 8f4d33cc11a4f7d58e37e03f7064d38490b0a595 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 10:25:40 -0300 Subject: [PATCH 1/3] fix(x): send JSON body on chunked media upload finalize X rejects empty finalize requests with "Request body must be a JSON object", breaking video and large-media posts that use the chunked upload flow. Co-authored-by: Cursor --- app/Services/Social/XPublisher.php | 8 ++- .../Services/Social/XPublisherTest.php | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 29cfd466..e1f2667a 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -243,9 +243,10 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy $initPayload['media_category'] = $mediaCategory; } - // INIT + // INIT — X requires application/json (MediaUploadConfigRequest). $initResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) + ->asJson() ->post("{$this->baseUrl}/media/upload/initialize", $initPayload); if ($initResponse->failed()) { @@ -307,9 +308,12 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy fclose($handle); } - // FINALIZE - Use the new v2 endpoint + // FINALIZE — runtime rejects empty / non-JSON bodies with + // "Request body must be a JSON object." Send `{}` explicitly + // (empty array encodes as `[]`, which is not an object). $finalizeResponse = $this->socialHttp()->withToken($this->accessToken) ->timeout(60) + ->withBody('{}', 'application/json') ->post("{$this->baseUrl}/media/upload/{$mediaId}/finalize"); if ($finalizeResponse->failed()) { diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 150716a5..f2e7d282 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -626,6 +626,72 @@ Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets')); }); +test('x publisher sends JSON object bodies for chunked upload initialize and finalize', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ], + ], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'media_id_json']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response(['data' => ['id' => 'media_id_json']], 200); + } + + if (str_contains($url, '/2/media/')) { + return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => '1111222233334444', 'text' => 'Hello from X!']], 200); + } + + return Http::response('fake-video-content', 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/2/media/upload/initialize')) { + return false; + } + + $contentType = $request->header('Content-Type')[0] ?? ''; + + return str_contains($contentType, 'application/json') + && data_get($request->data(), 'media_type') === 'video/mp4' + && data_get($request->data(), 'media_category') === 'tweet_video'; + }); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/finalize')) { + return false; + } + + $contentType = $request->header('Content-Type')[0] ?? ''; + + // Empty PHP array encodes as `[]`; X requires a JSON *object* (`{}`). + return str_contains($contentType, 'application/json') + && $request->body() === '{}'; + }); +}); + test('x publisher uploads a large video in sequential 1MB segments', function () { $this->post->update([ 'media' => [ From 35e7b703b6a174547d0c46b98d15885e6fb3173b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 10:30:34 -0300 Subject: [PATCH 2/3] fix(x): harden chunked media upload handling and coverage Fail hard when media processing fails, map media-specific X invalid-request errors clearly, and cover GIF/large-image/amplify/finalize/append failure paths. Co-authored-by: Cursor --- app/Exceptions/Social/XPublishException.php | 26 ++ app/Services/Social/XPublisher.php | 67 +-- .../Services/Social/XPublisherTest.php | 389 +++++++++++++++++- .../Social/XPublishExceptionTest.php | 32 ++ 4 files changed, 480 insertions(+), 34 deletions(-) diff --git a/app/Exceptions/Social/XPublishException.php b/app/Exceptions/Social/XPublishException.php index 6cbb1b5f..d08a3f6a 100644 --- a/app/Exceptions/Social/XPublishException.php +++ b/app/Exceptions/Social/XPublishException.php @@ -47,6 +47,32 @@ public static function fromApiResponse(mixed $response): static ); } + $firstErrorMessage = (string) data_get($body, 'errors.0.message', ''); + + if ( + str_contains((string) $rawResponse, 'media IDs are invalid') + || str_contains($firstErrorMessage, 'media IDs are invalid') + ) { + return new static( + userMessage: 'X rejected the attached media. Please re-upload and try again.', + category: ErrorCategory::MediaFormat, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + + if ( + str_contains((string) $rawResponse, 'Request body must be a JSON object') + || str_contains($firstErrorMessage, 'Request body must be a JSON object') + ) { + return new static( + userMessage: 'X rejected the media upload request. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: $typeSuffix ?: null, + rawResponse: $rawResponse, + ); + } + if ($statusCode === 413) { return new static( userMessage: 'Media chunk rejected by X (payload too large).', diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index e1f2667a..a3687490 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -61,8 +61,9 @@ public function publish(PostPlatform $postPlatform): array // v2 API returns data.id, v1 returns media_id $mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id')); if ($mediaId) { - $mediaIds[] = $mediaId; - $this->uploadAltText($mediaId, $mediaItem); + // X expects media_ids as strings in the tweets payload. + $mediaIds[] = (string) $mediaId; + $this->uploadAltText((string) $mediaId, $mediaItem); } } } @@ -218,15 +219,7 @@ private function uploadMedia($mediaItem): ?array $this->handleApiError($response); } - $responseData = $response->json(); - - $mediaId = $responseData['data']['id'] ?? $responseData['media_id'] ?? null; - - if ($isGif && $mediaId) { - $this->waitForProcessing($mediaId); - } - - return $responseData; + return $response->json(); } finally { @unlink($tempFile); } @@ -326,15 +319,24 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy $finalizeData = $finalizeResponse->json(); - // Wait for processing (videos need transcoding) - if (isset($finalizeData['processing_info']) || MediaType::classify($mimeType) === MediaType::Video) { - $this->waitForProcessing($mediaId); + // Videos/GIFs (and any finalize that reports processing_info) must finish + // before we attach the media_id to a tweet — otherwise X returns + // invalid-request / invalid media IDs. + $processingInfo = data_get($finalizeData, 'data.processing_info') + ?? data_get($finalizeData, 'processing_info'); + + if ( + $processingInfo !== null + || MediaType::classify($mimeType) === MediaType::Video + || MediaType::isGif($mimeType) + ) { + $this->waitForProcessing((string) $mediaId); } // Return in same format as simple upload return [ 'data' => [ - 'id' => $mediaId, + 'id' => (string) $mediaId, ], ]; } @@ -356,7 +358,7 @@ private function getMediaCategory(string $mimeType, int $fileSize): ?string return null; } - private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool + private function waitForProcessing(string $mediaId, int $maxAttempts = 20): void { for ($i = 0; $i < $maxAttempts; $i++) { $response = $this->getHttpClient() @@ -370,31 +372,44 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool } $responseData = $response->json(); + $processingInfo = data_get($responseData, 'processing_info') + ?? data_get($responseData, 'data.processing_info'); // If processing_info doesn't exist, assume it's ready - if (! isset($responseData['processing_info'])) { - return true; + if ($processingInfo === null) { + return; } - $state = $responseData['processing_info']['state'] ?? 'unknown'; + $state = data_get($processingInfo, 'state', 'unknown'); if ($state === 'succeeded') { - return true; + return; } if ($state === 'failed') { - $error = $responseData['processing_info']['error'] ?? 'Unknown error'; - Log::error('X media processing failed: '.$error); + $error = data_get($processingInfo, 'error', 'Unknown error'); + $rawError = is_string($error) ? $error : json_encode($error); - return false; + Log::error('X media processing failed: '.$rawError); + + throw new XPublishException( + userMessage: 'X could not process the uploaded media. Please try a different file.', + category: ErrorCategory::MediaFormat, + platformErrorCode: 'media-processing-failed', + rawResponse: $rawError ?: null, + ); } // Wait before checking again - $waitTime = $responseData['processing_info']['check_after_secs'] ?? 3; - sleep($waitTime); + $waitTime = (int) data_get($processingInfo, 'check_after_secs', 3); + sleep(max(0, $waitTime)); } - return false; + throw new XPublishException( + userMessage: 'X media processing timed out. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: 'media-processing-timeout', + ); } private function handleApiError(Response $response): never diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index f2e7d282..51fde0b2 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -249,15 +249,21 @@ } if (str_contains($url, '/finalize')) { - // Return processing_info so waitForProcessing is triggered + // Nested under data — matches the real X API shape. return Http::response([ - 'data' => ['id' => 'gif_media_555'], - 'processing_info' => ['state' => 'pending', 'check_after_secs' => 1], + 'data' => [ + 'id' => 'gif_media_555', + 'processing_info' => ['state' => 'pending', 'check_after_secs' => 0], + ], ], 200); } if (str_contains($url, '/2/media/gif_media_555')) { - return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + return Http::response([ + 'data' => [ + 'processing_info' => ['state' => 'succeeded'], + ], + ], 200); } if (str_contains($url, '/2/tweets')) { @@ -273,9 +279,28 @@ expect($result['id'])->toBe('9999888877776666'); // GIF uses chunked upload (not simple upload) - Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/upload/initialize')); + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/2/media/upload/initialize')) { + return false; + } + + $contentType = $request->header('Content-Type')[0] ?? ''; + + return str_contains($contentType, 'application/json') + && data_get($request->data(), 'media_type') === 'image/gif' + && data_get($request->data(), 'media_category') === 'tweet_gif'; + }); Http::assertSent(fn ($request) => str_contains($request->url(), '/append')); - Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize')); + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/finalize')) { + return false; + } + + $contentType = $request->header('Content-Type')[0] ?? ''; + + return str_contains($contentType, 'application/json') + && $request->body() === '{}'; + }); // waitForProcessing was called Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/gif_media_555')); }); @@ -621,9 +646,22 @@ expect($result['url'])->toContain('x.com/testuser/status/9876543210987654321'); Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/upload/initialize')); - Http::assertSent(fn ($request) => str_contains($request->url(), '/append')); + Http::assertSent(function ($request) { + return str_contains($request->url(), '/append') + && (int) data_get($request->data(), 'segment_index') === 0; + }); Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize')); - Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets')); + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/2/tweets')) { + return false; + } + + $mediaIds = data_get($request->data(), 'media.media_ids'); + + return is_array($mediaIds) + && $mediaIds === ['media_id_999'] + && is_string($mediaIds[0]); + }); }); test('x publisher sends JSON object bodies for chunked upload initialize and finalize', function () { @@ -753,3 +791,338 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(XPublishException::class, 'Could not fetch the media to upload to X'); }); + +test('x publisher uses simple upload for small images and skips chunked finalize', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-image', + 'path' => 'media/2026-01/photo.jpg', + 'url' => 'https://example.com/media/2026-01/photo.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'photo.jpg', + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'x_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/media/upload') + && ! str_contains($url, '/initialize') + && ! str_contains($url, '/append') + && ! str_contains($url, '/finalize')) { + return Http::response(['data' => ['id' => 'simple_image_1']], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => 'tweet_simple_1']], 200); + } + + return Http::response(file_get_contents(__DIR__.'/../../../fixtures/1x1.png'), 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('tweet_simple_1'); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/initialize')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/finalize')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/media/upload') + && ! str_contains($request->url(), '/initialize')); +}); + +test('x publisher uses chunked upload for images larger than 5MB', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-large-image', + 'path' => 'media/2026-01/large.jpg', + 'url' => 'https://example.com/media/2026-01/large.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'large.jpg', + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function () { + $optimized = tempnam(sys_get_temp_dir(), 'x_opt_'); + file_put_contents($optimized, str_repeat('x', (5 * 1024 * 1024) + 10)); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'large_image_1']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response(['data' => ['id' => 'large_image_1']], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => 'tweet_large_image']], 200); + } + + return Http::response('tiny', 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/2/media/upload/initialize')) { + return false; + } + + return data_get($request->data(), 'media_type') === 'image/jpeg' + && data_get($request->data(), 'media_category') === 'tweet_image' + && data_get($request->data(), 'total_bytes') > 5 * 1024 * 1024; + }); + Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize') + && $request->body() === '{}'); +}); + +test('x publisher uses amplify_video category for videos larger than 15MB', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-amplify', + 'path' => 'media/2026-01/big.mp4', + 'url' => 'https://example.com/media/2026-01/big.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'big.mp4', + ], + ], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'amplify_1']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response(['data' => ['id' => 'amplify_1']], 200); + } + + if (str_contains($url, '/2/media/')) { + return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => 'tweet_amplify']], 200); + } + + return Http::response(str_repeat('v', (15 * 1024 * 1024) + 10), 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/2/media/upload/initialize')) { + return false; + } + + return data_get($request->data(), 'media_category') === 'amplify_video' + && data_get($request->data(), 'total_bytes') > 15 * 1024 * 1024; + }); +}); + +test('x publisher fails when chunked finalize is rejected by X', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/clip.mp4', + 'url' => 'https://example.com/media/2026-01/clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + ], + ], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'media_fail_finalize']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response([ + 'detail' => 'One or more parameters to your request was invalid.', + 'errors' => [['message' => 'Request body must be a JSON object.']], + 'title' => 'Invalid Request', + 'type' => 'https://api.x.com/2/problems/invalid-request', + ], 400); + } + + return Http::response('fake-video-content', 200); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(XPublishException::class, 'X rejected the media upload request'); +}); + +test('x publisher fails when append is rejected by X', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/clip.mp4', + 'url' => 'https://example.com/media/2026-01/clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + ], + ], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'media_fail_append']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response([ + 'title' => 'Invalid Request', + 'detail' => 'Segments do not add up to provided total file size.', + 'type' => 'https://api.x.com/2/problems/invalid-request', + ], 400); + } + + return Http::response('fake-video-content', 200); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(XPublishException::class); +}); + +test('x publisher fails when media processing reports failed', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/clip.mp4', + 'url' => 'https://example.com/media/2026-01/clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + ], + ], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'media_proc_fail']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response([ + 'data' => [ + 'id' => 'media_proc_fail', + 'processing_info' => ['state' => 'pending', 'check_after_secs' => 0], + ], + ], 200); + } + + if (str_contains($url, '/2/media/media_proc_fail')) { + return Http::response([ + 'data' => [ + 'processing_info' => [ + 'state' => 'failed', + 'error' => 'Unsupported codec', + ], + ], + ], 200); + } + + return Http::response('fake-video-content', 200); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(XPublishException::class, 'X could not process the uploaded media'); +}); + +test('x publisher fails when tweet rejects invalid media ids', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/clip.mp4', + 'url' => 'https://example.com/media/2026-01/clip.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'clip.mp4', + ], + ], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'media_invalid']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response(['data' => ['id' => 'media_invalid']], 200); + } + + if (str_contains($url, '/2/media/')) { + return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response([ + 'detail' => 'One or more parameters to your request was invalid.', + 'errors' => [[ + 'parameters' => ['media.media_ids' => ['media_invalid']], + 'message' => 'Your media IDs are invalid.', + ]], + 'title' => 'Invalid Request', + 'type' => 'https://api.x.com/2/problems/invalid-request', + ], 400); + } + + return Http::response('fake-video-content', 200); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(XPublishException::class, 'X rejected the attached media'); +}); diff --git a/tests/Unit/Exceptions/Social/XPublishExceptionTest.php b/tests/Unit/Exceptions/Social/XPublishExceptionTest.php index 978d7413..42fcf5ba 100644 --- a/tests/Unit/Exceptions/Social/XPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/XPublishExceptionTest.php @@ -134,6 +134,38 @@ ->and($exception->userMessage)->toBe('Video exceeds the 2-minute limit.'); }); +test('invalid media IDs maps to MediaFormat category', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/invalid-request', + 'title' => 'Invalid Request', + 'detail' => 'One or more parameters to your request was invalid.', + 'errors' => [['message' => 'Your media IDs are invalid.']], + ], 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('X rejected the attached media. Please re-upload and try again.'); +}); + +test('JSON object body requirement maps to ServerError category', function () { + $response = Http::response([ + 'type' => 'https://api.x.com/2/problems/invalid-request', + 'title' => 'Invalid Request', + 'detail' => 'One or more parameters to your request was invalid.', + 'errors' => [['message' => 'Request body must be a JSON object.']], + ], 400); + + $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 rejected the media upload request. Please try again.'); +}); + test('platform returns x', function () { $response = Http::response([ 'type' => 'https://api.x.com/2/problems/some-unknown-problem', From 38d74e358dde6c034a1dc6953ea0907ce500f505 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 10:43:32 -0300 Subject: [PATCH 3/3] fix(x): poll official media upload STATUS endpoint Use GET /2/media/upload?media_id=&command=STATUS per X API docs, and persist redacted raw_response on SocialPublishException failures for supportability. Co-authored-by: Cursor --- app/Jobs/PublishToSocialPlatform.php | 1 + app/Services/Social/XPublisher.php | 7 +- .../Jobs/PublishToSocialPlatformTest.php | 1 + .../Services/Social/XPublisherTest.php | 66 +++++++++++++++---- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 090540f2..88a46d6e 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -157,6 +157,7 @@ public function handle(): void 'failed_at' => now()->toIso8601String(), 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), 'media_count' => count($this->postPlatform->post->media ?? []), + 'raw_response' => $e->context()['raw_response'], ]); break; } catch (\Throwable $e) { diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index a3687490..ae4aee94 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -361,8 +361,13 @@ private function getMediaCategory(string $mimeType, int $fileSize): ?string private function waitForProcessing(string $mediaId, int $maxAttempts = 20): void { for ($i = 0; $i < $maxAttempts; $i++) { + // Official status endpoint: GET /2/media/upload?media_id=...&command=STATUS + // (not GET /2/media/{id} — that path is not the upload-status contract). $response = $this->getHttpClient() - ->get("{$this->baseUrl}/media/{$mediaId}"); + ->get("{$this->baseUrl}/media/upload", [ + 'media_id' => $mediaId, + 'command' => 'STATUS', + ]); if ($response->failed()) { Log::error('X media status check error', ['body' => $this->redactResponseBody($response->body())]); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 1c7230e9..8657bbd8 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -729,6 +729,7 @@ expect($this->postPlatform->error_context['category'])->toBe('permission'); expect($this->postPlatform->error_context['platform_error_code'])->toBe('403'); expect($this->postPlatform->error_context['content_length'])->toBe(11); + expect($this->postPlatform->error_context['raw_response'])->toBe('{"error": "forbidden"}'); }); test('publish to social platform fails when scopes are missing', function () { diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 51fde0b2..b3af28b6 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -14,8 +14,33 @@ use App\Services\Media\MediaOptimizer; use App\Services\Social\XPublisher; use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +/** + * Official X upload STATUS: GET /2/media/upload?media_id=...&command=STATUS + */ +function isXMediaUploadStatusRequest(Request $request): bool +{ + if (strtoupper($request->method()) !== 'GET') { + return false; + } + + $url = $request->url(); + + if ( + ! str_contains($url, '/media/upload') + || str_contains($url, '/initialize') + || str_contains($url, '/append') + || str_contains($url, '/finalize') + ) { + return false; + } + + return str_contains($url, 'media_id=') + || filled(data_get($request->data(), 'media_id')); +} + beforeEach(function () { $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); @@ -258,7 +283,7 @@ ], 200); } - if (str_contains($url, '/2/media/gif_media_555')) { + if (isXMediaUploadStatusRequest($request)) { return Http::response([ 'data' => [ 'processing_info' => ['state' => 'succeeded'], @@ -301,8 +326,18 @@ return str_contains($contentType, 'application/json') && $request->body() === '{}'; }); - // waitForProcessing was called - Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/gif_media_555')); + // waitForProcessing polls the official STATUS endpoint + Http::assertSent(function ($request) { + return isXMediaUploadStatusRequest($request) + && ( + str_contains($request->url(), 'media_id=gif_media_555') + || data_get($request->data(), 'media_id') === 'gif_media_555' + ) + && ( + str_contains($request->url(), 'command=STATUS') + || data_get($request->data(), 'command') === 'STATUS' + ); + }); }); test('x publisher recovers a missing mime type from the downloaded bytes', function () { @@ -585,6 +620,10 @@ return Http::response(['data' => ['id' => 'video_media_777']], 200); } + if (isXMediaUploadStatusRequest($request)) { + return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); + } + if (str_contains($url, '/2/tweets')) { return Http::response(['data' => ['id' => '7778889990', 'text' => 'Hello from X!']], 200); } @@ -627,9 +666,9 @@ return Http::response(['data' => ['id' => 'media_id_999']], 200); } - if (str_contains($url, '/2/media/')) { - // STATUS check: GET /2/media/{id} - return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + if (isXMediaUploadStatusRequest($request)) { + // STATUS check: GET /2/media/upload?media_id=...&command=STATUS + return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); } if (str_contains($url, '/2/tweets')) { @@ -651,6 +690,7 @@ && (int) data_get($request->data(), 'segment_index') === 0; }); Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize')); + Http::assertSent(fn ($request) => isXMediaUploadStatusRequest($request)); Http::assertSent(function ($request) { if (! str_contains($request->url(), '/2/tweets')) { return false; @@ -692,8 +732,8 @@ return Http::response(['data' => ['id' => 'media_id_json']], 200); } - if (str_contains($url, '/2/media/')) { - return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + if (isXMediaUploadStatusRequest($request)) { + return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); } if (str_contains($url, '/2/tweets')) { @@ -762,8 +802,8 @@ return Http::response(['data' => ['id' => 'media_id_999']], 200); } - if (str_contains($url, '/2/media/')) { - return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + if (isXMediaUploadStatusRequest($request)) { + return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); } if (str_contains($url, '/2/tweets')) { @@ -927,7 +967,7 @@ return Http::response(['data' => ['id' => 'amplify_1']], 200); } - if (str_contains($url, '/2/media/')) { + if (isXMediaUploadStatusRequest($request)) { return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); } @@ -1058,7 +1098,7 @@ ], 200); } - if (str_contains($url, '/2/media/media_proc_fail')) { + if (isXMediaUploadStatusRequest($request)) { return Http::response([ 'data' => [ 'processing_info' => [ @@ -1104,7 +1144,7 @@ return Http::response(['data' => ['id' => 'media_invalid']], 200); } - if (str_contains($url, '/2/media/')) { + if (isXMediaUploadStatusRequest($request)) { return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200); }