From 38d74e358dde6c034a1dc6953ea0907ce500f505 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 24 Jul 2026 10:43:32 -0300 Subject: [PATCH] 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); }