2026-01-18 18:56:51 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
use App\Enums\PostPlatform\ContentType;
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
2026-06-28 23:37:04 +00:00
|
|
|
use App\Exceptions\Social\XPublishException;
|
2026-01-18 18:56:51 +00:00
|
|
|
use App\Exceptions\TokenExpiredException;
|
|
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Models\Workspace;
|
2026-06-28 22:29:14 +00:00
|
|
|
use App\Services\Media\MediaOptimizer;
|
2026-01-18 18:56:51 +00:00
|
|
|
use App\Services\Social\XPublisher;
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
2026-07-24 13:43:32 +00:00
|
|
|
use Illuminate\Http\Client\Request;
|
2026-01-18 18:56:51 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
/**
|
|
|
|
|
* 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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
beforeEach(function () {
|
|
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->x()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform_user_id' => '123456789',
|
|
|
|
|
'username' => 'testuser',
|
|
|
|
|
'token_expires_at' => now()->addHours(2),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Hello from X!',
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'platform' => Platform::X,
|
|
|
|
|
'content_type' => ContentType::XPost,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher = new XPublisher;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher can publish text-only post', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/tweets' => Http::response([
|
|
|
|
|
'data' => [
|
|
|
|
|
'id' => '1234567890123456789',
|
|
|
|
|
'text' => 'Hello from X!',
|
|
|
|
|
],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result)->toHaveKey('id');
|
|
|
|
|
expect($result)->toHaveKey('url');
|
|
|
|
|
expect($result['id'])->toBe('1234567890123456789');
|
|
|
|
|
expect($result['url'])->toBe('https://x.com/testuser/status/1234567890123456789');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/2/tweets')
|
|
|
|
|
&& $request['text'] === 'Hello from X!';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-03 11:30:58 +00:00
|
|
|
test('x publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
|
|
|
|
$this->socialAccount->update([
|
|
|
|
|
'token_expires_at' => now()->addMinutes(5),
|
|
|
|
|
'refresh_token' => 'original-refresh-token',
|
|
|
|
|
]);
|
|
|
|
|
$originalAccessToken = $this->socialAccount->access_token;
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '999']], 200),
|
|
|
|
|
config('trypost.platforms.x.api').'/oauth2/token' => Http::response([
|
|
|
|
|
'access_token' => 'should-not-be-used',
|
|
|
|
|
'refresh_token' => 'should-not-be-used',
|
|
|
|
|
'expires_in' => 7200,
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
// X single-use refresh tokens: a still-valid access_token must NOT be rotated.
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/oauth2/token'));
|
|
|
|
|
$this->socialAccount->refresh();
|
|
|
|
|
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
|
|
|
|
expect($this->socialAccount->refresh_token)->toBe('original-refresh-token');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('x publisher uses bearer token authentication', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/tweets' => Http::response([
|
|
|
|
|
'data' => [
|
|
|
|
|
'id' => '1234567890123456789',
|
|
|
|
|
],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request->hasHeader('Authorization')
|
|
|
|
|
&& str_starts_with($request->header('Authorization')[0], 'Bearer ');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher throws exception on api error', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/tweets' => Http::response([
|
|
|
|
|
'detail' => 'You are not allowed to create a Tweet with duplicate content.',
|
|
|
|
|
'type' => 'about:blank',
|
|
|
|
|
'title' => 'Forbidden',
|
|
|
|
|
'status' => 403,
|
|
|
|
|
], 403),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher throws token expired exception on auth error', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/tweets' => Http::response([
|
|
|
|
|
'title' => 'Unauthorized',
|
|
|
|
|
'detail' => 'Unauthorized',
|
|
|
|
|
'status' => 401,
|
|
|
|
|
], 401),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher refreshes token when expired', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/oauth2/token' => Http::response([
|
|
|
|
|
'access_token' => 'new-access-token',
|
|
|
|
|
'refresh_token' => 'new-refresh-token',
|
|
|
|
|
'expires_in' => 7200,
|
|
|
|
|
], 200),
|
|
|
|
|
'https://api.x.com/2/tweets' => Http::response([
|
|
|
|
|
'data' => [
|
|
|
|
|
'id' => '1234567890123456789',
|
|
|
|
|
],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
2026-01-25 21:37:54 +00:00
|
|
|
if (! str_contains($request->url(), 'oauth2/token')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify Basic Auth is used for confidential client authentication
|
|
|
|
|
$authHeader = $request->header('Authorization')[0] ?? '';
|
|
|
|
|
|
|
|
|
|
return str_starts_with($authHeader, 'Basic ');
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->socialAccount->refresh();
|
|
|
|
|
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher includes media ids in post when media uploaded', function () {
|
|
|
|
|
// Note: This test verifies the post structure when media IDs are present
|
|
|
|
|
// Actual media upload requires file_get_contents which needs real files
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/tweets' => Http::response([
|
|
|
|
|
'data' => [
|
|
|
|
|
'id' => '1234567890123456789',
|
|
|
|
|
],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/2/tweets');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
test('x publisher throws exception with empty content and no media', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update(['content' => '']);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
2026-03-29 22:24:28 +00:00
|
|
|
->toThrow(Exception::class, 'X posts require either text or media');
|
2026-03-29 20:04:01 +00:00
|
|
|
});
|
2026-01-18 18:56:51 +00:00
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
test('x publisher throws exception with null content and no media', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update(['content' => null]);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
2026-03-29 22:24:28 +00:00
|
|
|
->toThrow(Exception::class, 'X posts require either text or media');
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher throws exception when no refresh token available', function () {
|
|
|
|
|
$this->socialAccount->update([
|
|
|
|
|
'token_expires_at' => now()->subHour(),
|
|
|
|
|
'refresh_token' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'No refresh token available for X account');
|
|
|
|
|
});
|
2026-05-12 21:46:06 +00:00
|
|
|
|
|
|
|
|
test('x publisher throws TokenExpiredException when refresh_token is rejected by X', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/oauth2/token' => Http::response([
|
|
|
|
|
'error' => 'invalid_request',
|
|
|
|
|
'error_description' => 'Value passed for the token was invalid.',
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'Value passed for the token was invalid.');
|
|
|
|
|
});
|
2026-04-01 12:32:43 +00:00
|
|
|
|
|
|
|
|
test('x publisher handles gif upload with processing', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-gif',
|
|
|
|
|
'path' => 'media/2026-01/animated.gif',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/animated.gif',
|
|
|
|
|
'mime_type' => 'image/gif',
|
|
|
|
|
'original_filename' => 'animated.gif',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-04-01 12:32:43 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/media/upload/initialize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'gif_media_555']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/append')) {
|
|
|
|
|
return Http::response(null, 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/finalize')) {
|
2026-07-24 13:30:34 +00:00
|
|
|
// Nested under data — matches the real X API shape.
|
2026-04-01 12:32:43 +00:00
|
|
|
return Http::response([
|
2026-07-24 13:30:34 +00:00
|
|
|
'data' => [
|
|
|
|
|
'id' => 'gif_media_555',
|
|
|
|
|
'processing_info' => ['state' => 'pending', 'check_after_secs' => 0],
|
|
|
|
|
],
|
2026-04-01 12:32:43 +00:00
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
2026-07-24 13:30:34 +00:00
|
|
|
return Http::response([
|
|
|
|
|
'data' => [
|
|
|
|
|
'processing_info' => ['state' => 'succeeded'],
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
2026-04-01 12:32:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '9999888877776666', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GIF download
|
|
|
|
|
return Http::response('fake-gif-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('9999888877776666');
|
|
|
|
|
|
|
|
|
|
// GIF uses chunked upload (not simple upload)
|
2026-07-24 13:30:34 +00:00
|
|
|
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';
|
|
|
|
|
});
|
2026-04-01 12:32:43 +00:00
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/append'));
|
2026-07-24 13:30:34 +00:00
|
|
|
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() === '{}';
|
|
|
|
|
});
|
2026-07-24 13:43:32 +00:00
|
|
|
// 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'
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-04-01 12:32:43 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-28 22:29:14 +00:00
|
|
|
test('x publisher recovers a missing mime type from the downloaded bytes', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
['url' => 'https://cdn.example.com/listing'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_111']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '1212121212', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('1212121212');
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/media/upload'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 11:56:42 +00:00
|
|
|
test('x publisher sends image alt text to the media metadata endpoint', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/test-image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test.jpg',
|
|
|
|
|
'meta' => ['alt_text' => 'a red bike'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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/metadata')) {
|
|
|
|
|
return Http::response([], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/media/upload')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_alt_1']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '1212121212', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/media/metadata')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data_get($request->data(), 'id') === 'media_id_alt_1'
|
|
|
|
|
&& data_get($request->data(), 'metadata.alt_text.text') === 'a red bike';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher truncates alt text to the platform max length', function () {
|
|
|
|
|
$longAlt = str_repeat('a', 1200);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/test-image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test.jpg',
|
|
|
|
|
'meta' => ['alt_text' => $longAlt],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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/metadata')) {
|
|
|
|
|
return Http::response([], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/media/upload')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_alt_2']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '1212121213', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/media/metadata')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data_get($request->data(), 'metadata.alt_text.text') === str_repeat('a', 1000);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher does not call media metadata when no alt text is set', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/test-image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test.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')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_alt_3']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '1212121214', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media/metadata'));
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher still posts the tweet when the alt text metadata call fails', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/test-image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test.jpg',
|
|
|
|
|
'meta' => ['alt_text' => 'a red bike'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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/metadata')) {
|
|
|
|
|
throw new ConnectionException('Connection timed out');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/media/upload')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_alt_fail']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '5551112223', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(
|
|
|
|
|
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
|
|
|
|
|
200,
|
|
|
|
|
['Content-Type' => 'image/png'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('5551112223');
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher does not send alt text metadata for a video even if it carries alt text', 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',
|
|
|
|
|
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/media/upload/initialize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'video_media_777']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/append')) {
|
|
|
|
|
return Http::response(null, 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/finalize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'video_media_777']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
|
|
|
|
return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200);
|
|
|
|
|
}
|
|
|
|
|
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '7778889990', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('7778889990');
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media/metadata'));
|
2026-07-11 11:56:42 +00:00
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
test('x publisher uploads video via chunked upload', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$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',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-04-01 12:32:43 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
// Order matters: more specific patterns first
|
|
|
|
|
if (str_contains($url, '/2/media/upload/initialize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_999']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/append')) {
|
|
|
|
|
return Http::response(null, 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/finalize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_999']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
|
|
|
|
// STATUS check: GET /2/media/upload?media_id=...&command=STATUS
|
|
|
|
|
return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200);
|
2026-04-01 12:32:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '9876543210987654321', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download (any URL including relative paths in test env)
|
|
|
|
|
return Http::response('fake-video-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('9876543210987654321');
|
|
|
|
|
expect($result['url'])->toContain('x.com/testuser/status/9876543210987654321');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/upload/initialize'));
|
2026-07-24 13:30:34 +00:00
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/append')
|
|
|
|
|
&& (int) data_get($request->data(), 'segment_index') === 0;
|
|
|
|
|
});
|
2026-04-01 12:32:43 +00:00
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize'));
|
2026-07-24 13:43:32 +00:00
|
|
|
Http::assertSent(fn ($request) => isXMediaUploadStatusRequest($request));
|
2026-07-24 13:30:34 +00:00
|
|
|
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]);
|
|
|
|
|
});
|
2026-04-01 12:32:43 +00:00
|
|
|
});
|
2026-06-26 00:26:15 +00:00
|
|
|
|
2026-07-24 13:25:40 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
|
|
|
|
return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200);
|
2026-07-24 13:25:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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() === '{}';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-26 00:26:15 +00:00
|
|
|
test('x publisher uploads a large video in sequential 1MB segments', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-video',
|
|
|
|
|
'path' => 'media/2026-01/big-video.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/big-video.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'big-video.mp4',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$appendCount = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$appendCount) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/media/upload/initialize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_999']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/append')) {
|
|
|
|
|
$appendCount++;
|
|
|
|
|
|
|
|
|
|
return Http::response(null, 204);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/finalize')) {
|
|
|
|
|
return Http::response(['data' => ['id' => 'media_id_999']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
|
|
|
|
return Http::response(['data' => ['processing_info' => ['state' => 'succeeded']]], 200);
|
2026-06-26 00:26:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/2/tweets')) {
|
|
|
|
|
return Http::response(['data' => ['id' => '9876543210987654321', 'text' => 'Hello from X!']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ~2.5MB download -> at least three 1MB segments.
|
|
|
|
|
return Http::response(str_repeat('x', (int) (2.5 * 1024 * 1024)), 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($appendCount)->toBeGreaterThan(1);
|
|
|
|
|
});
|
2026-06-28 23:37:04 +00:00
|
|
|
|
|
|
|
|
test('x publisher fails cleanly when media cannot be downloaded', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
['url' => 'https://cdn.example.com/listing', 'mime_type' => 'image/jpeg'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake(['cdn.example.com/listing' => Http::response(null, 404)]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(XPublishException::class, 'Could not fetch the media to upload to X');
|
|
|
|
|
});
|
2026-07-24 13:30:34 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
2026-07-24 13:30:34 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
2026-07-24 13:30:34 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 13:43:32 +00:00
|
|
|
if (isXMediaUploadStatusRequest($request)) {
|
2026-07-24 13:30:34 +00:00
|
|
|
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');
|
|
|
|
|
});
|
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 18:31:05 +00:00
|
|
|
|
|
|
|
|
test('x publisher sends the tweet with links defused', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
$this->post->update(['content' => 'New post: https://trypost.it/blog']);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '111']], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets')
|
|
|
|
|
&& $request['text'] === 'New post: trypost(.)it/blog');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher leaves the tweet untouched when defusing is disabled', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', false);
|
|
|
|
|
$this->post->update(['content' => 'New post: https://trypost.it/blog']);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '111']], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets')
|
|
|
|
|
&& $request['text'] === 'New post: https://trypost.it/blog');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher rejects a post that only fits before its links are defused', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
$this->post->update(['content' => str_repeat('a', 271).' acme.com']);
|
|
|
|
|
|
|
|
|
|
Http::fake([config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '1']], 200)]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Content exceeds X limit of 280 characters (282 provided).');
|
|
|
|
|
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher accepts a post that only fits once its links are defused', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
$this->post->update(['content' => str_repeat('a', 263).' https://acme.com/x']);
|
|
|
|
|
|
|
|
|
|
Http::fake([config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '1']], 200)]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => mb_strlen($request['text']) === 276);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('x publisher does not count html markup toward the character limit', function () {
|
|
|
|
|
$this->post->update(['content' => '<p>'.str_repeat('a', 275).'</p>']);
|
|
|
|
|
|
|
|
|
|
Http::fake([config('trypost.platforms.x.api').'/tweets' => Http::response(['data' => ['id' => '1']], 200)]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => mb_strlen($request['text']) === 275);
|
|
|
|
|
});
|