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 <cursoragent@cursor.com>
This commit is contained in:
Paulo Castellano 2026-07-24 10:43:32 -03:00
parent 35e7b703b6
commit 38d74e358d
4 changed files with 61 additions and 14 deletions

View file

@ -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) {

View file

@ -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())]);

View file

@ -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 () {

View file

@ -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);
}