trypost/tests/Unit/Exceptions/Social/XPublishExceptionTest.php
Paulo Castellano 7d82cc3f12 fix(x): drop chunked upload chunk size from 5MB to 1MB
Production was returning HTTP 413 with empty body on the first APPEND
segment of every video upload to X v2 — surfacing to users as 'An
unknown X error occurred.'

Empty-body 413 is the classic signature of an edge/CDN rejection: the
X gateway is denying the request before X's application code sees it.
The X v2 reference docs say 'max chunk size: 5MB', but every canonical
reference uses 1MB:
- X's official Python quickstart: `chunk_size = 1024 * 1024`
- X's official JavaScript quickstart: `const chunkSize = 1024 * 1024`
- twitter-api-v2 (the most-used Node SDK, used by Postiz et al.):
  `chunkSize: number = 1024 * 1024`

5MB plus multipart-form overhead apparently exceeds an undocumented
edge limit. 1MB is the empirically safe size everyone converges on.

Changes:
- XPublisher chunked APPEND now uses 1MB chunks. An 8MB video uploads
  as 8 segments instead of 2; more roundtrips but actually succeeds.
- Set explicit Content-Type on each chunk attach (matches the simple
  upload path in the same file).
- XPublishException::fromApiResponse maps HTTP 413 to
  ErrorCategory::MediaFormat with the message 'Media chunk rejected
  by X (payload too large).' so we don't surface 413 as 'unknown' if
  it ever recurs.

Test: unit test covering the 413→MediaFormat mapping. Full suite green
(1503 passed, 2 skipped).
2026-05-12 20:02:23 -03:00

149 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\XPublishException;
use App\Exceptions\TokenExpiredException;
use Illuminate\Support\Facades\Http;
test('HTTP 401 throws TokenExpiredException', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/not-authorized-for-resource',
'title' => 'Unauthorized',
'detail' => 'Could not authenticate you.',
], 401);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
XPublishException::fromApiResponse($fakeResponse);
})->throws(TokenExpiredException::class);
test('type unsupported-authentication throws TokenExpiredException', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/unsupported-authentication',
'title' => 'Unsupported Authentication',
'detail' => 'Authentication method not supported.',
], 403);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
XPublishException::fromApiResponse($fakeResponse);
})->throws(TokenExpiredException::class);
test('type usage-capped maps to RateLimit category', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/usage-capped',
'title' => 'Usage Capped',
'detail' => 'You have exceeded your monthly usage cap.',
], 403);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
$exception = XPublishException::fromApiResponse($fakeResponse);
expect($exception)
->toBeInstanceOf(XPublishException::class)
->and($exception->category)->toBe(ErrorCategory::RateLimit)
->and($exception->userMessage)->toBe('Usage limit exceeded. Please try again later.');
});
test('type client-forbidden maps to Permission category', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/client-forbidden',
'title' => 'Client Forbidden',
'detail' => 'This app is not enrolled.',
], 403);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
$exception = XPublishException::fromApiResponse($fakeResponse);
expect($exception->category)->toBe(ErrorCategory::Permission)
->and($exception->userMessage)->toBe('App not enrolled or lacks required access.');
});
test('HTTP 500 maps to ServerError category', function () {
$response = Http::response([
'title' => 'Internal Server Error',
'detail' => 'Something went wrong on our end.',
], 500);
$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 server error. Please try again later.');
});
test('HTTP 413 with empty body maps to MediaFormat category', function () {
$response = Http::response('', 413);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
$exception = XPublishException::fromApiResponse($fakeResponse);
expect($exception->category)->toBe(ErrorCategory::MediaFormat)
->and($exception->userMessage)->toBe('Media chunk rejected by X (payload too large).')
->and($exception->platformErrorCode)->toBe('413');
});
test('unknown type maps to Unknown category with detail as message', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/some-unknown-problem',
'title' => 'Some Unknown Problem',
'detail' => 'An unknown issue occurred.',
], 400);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
$exception = XPublishException::fromApiResponse($fakeResponse);
expect($exception->category)->toBe(ErrorCategory::Unknown)
->and($exception->userMessage)->toBe('An unknown issue occurred.');
});
test('body containing "invalid URL" maps to ContentPolicy category', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/invalid-request',
'title' => 'Invalid Request',
'detail' => 'The post contains an invalid URL in the content.',
], 400);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
$exception = XPublishException::fromApiResponse($fakeResponse);
expect($exception->category)->toBe(ErrorCategory::ContentPolicy)
->and($exception->userMessage)->toBe('Post contains an invalid URL.');
});
test('body containing "video longer than 2 minutes" maps to MediaFormat category', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/invalid-request',
'title' => 'Invalid Request',
'detail' => 'The video longer than 2 minutes cannot be uploaded.',
], 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('Video exceeds the 2-minute limit.');
});
test('platform returns x', function () {
$response = Http::response([
'type' => 'https://api.x.com/2/problems/some-unknown-problem',
'title' => 'Error',
'detail' => 'Some detail.',
], 400);
$fakeResponse = Http::fake(['*' => $response])->post('https://api.x.com/test');
$exception = XPublishException::fromApiResponse($fakeResponse);
expect($exception->platform())->toBe('x');
});