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-08-13 15:18:19 +00:00
|
|
|
use App\Exceptions\Social\ThreadsMediaContainerNotFoundException;
|
|
|
|
|
use App\Exceptions\Social\ThreadsPublishException;
|
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;
|
|
|
|
|
use App\Services\Social\ThreadsPublisher;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
2026-08-13 15:18:19 +00:00
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
use Illuminate\Support\Sleep;
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
beforeEach(function () {
|
2026-08-13 15:18:19 +00:00
|
|
|
Sleep::fake();
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->threads()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform_user_id' => '123456789',
|
|
|
|
|
'username' => 'testuser',
|
|
|
|
|
'token_expires_at' => now()->addDays(60),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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 Threads!',
|
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::Threads,
|
|
|
|
|
'content_type' => ContentType::ThreadsPost,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher = new ThreadsPublisher;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher can publish text-only post', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-123456789',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result)->toHaveKey('id');
|
|
|
|
|
expect($result)->toHaveKey('url');
|
|
|
|
|
expect($result['id'])->toBe('post-123456789');
|
|
|
|
|
expect($result['url'])->toBe('https://www.threads.net/@testuser/post/ABC123');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/threads')
|
|
|
|
|
&& str_contains($request->url(), '123456789');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher can publish image post', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$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',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-123456789',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('post-123456789');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/threads');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
test('threads publisher recreates a missing image container before retrying publication', function () {
|
|
|
|
|
Log::spy();
|
|
|
|
|
|
|
|
|
|
$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',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$containerCreations = 0;
|
|
|
|
|
$publicationAttempts = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$containerCreations, &$publicationAttempts) {
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads')) {
|
|
|
|
|
$containerCreations++;
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => "container-{$containerCreations}"], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($request->url(), '/container-')) {
|
|
|
|
|
return Http::response(['status' => 'FINISHED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads_publish')) {
|
|
|
|
|
$publicationAttempts++;
|
|
|
|
|
|
|
|
|
|
if ($publicationAttempts === 1) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'The requested resource does not exist',
|
|
|
|
|
'code' => 24,
|
|
|
|
|
'error_subcode' => 4279009,
|
|
|
|
|
],
|
|
|
|
|
], 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => 'post-after-retry'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/RETRY',
|
|
|
|
|
], 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('post-after-retry')
|
|
|
|
|
->and($containerCreations)->toBe(2)
|
|
|
|
|
->and($publicationAttempts)->toBe(2);
|
|
|
|
|
|
|
|
|
|
Sleep::assertSequence([
|
|
|
|
|
Sleep::for(2)->seconds(),
|
|
|
|
|
Sleep::for(2)->seconds(),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Log::shouldHaveReceived('warning')->once();
|
|
|
|
|
Log::shouldNotHaveReceived('error');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher does not retry a missing media response from container creation', 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',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$containerCreations = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$containerCreations) {
|
|
|
|
|
$containerCreations++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'The requested resource does not exist',
|
|
|
|
|
'code' => 24,
|
|
|
|
|
'error_subcode' => 4279009,
|
|
|
|
|
],
|
|
|
|
|
], 400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(ThreadsPublishException::class);
|
|
|
|
|
|
|
|
|
|
expect($containerCreations)->toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher stops after three missing media containers', 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',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$containerCreations = 0;
|
|
|
|
|
$publicationAttempts = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$containerCreations, &$publicationAttempts) {
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads')) {
|
|
|
|
|
$containerCreations++;
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => "container-{$containerCreations}"], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($request->url(), '/container-')) {
|
|
|
|
|
return Http::response(['status' => 'FINISHED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads_publish')) {
|
|
|
|
|
$publicationAttempts++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'The requested resource does not exist',
|
|
|
|
|
'code' => 24,
|
|
|
|
|
'error_subcode' => 4279009,
|
|
|
|
|
],
|
|
|
|
|
], 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response([], 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
$this->fail('Expected ThreadsPublishException was not thrown.');
|
|
|
|
|
} catch (ThreadsPublishException $exception) {
|
|
|
|
|
expect($exception)->toBeInstanceOf(ThreadsMediaContainerNotFoundException::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect($containerCreations)->toBe(3)
|
|
|
|
|
->and($publicationAttempts)->toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher does not retry unrelated client errors', 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',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$containerCreations = 0;
|
|
|
|
|
$publicationAttempts = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$containerCreations, &$publicationAttempts) {
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads')) {
|
|
|
|
|
$containerCreations++;
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => 'container-1'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($request->url(), '/container-1')) {
|
|
|
|
|
return Http::response(['status' => 'FINISHED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads_publish')) {
|
|
|
|
|
$publicationAttempts++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'Invalid parameter',
|
|
|
|
|
'code' => 100,
|
|
|
|
|
],
|
|
|
|
|
], 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response([], 500);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(ThreadsPublishException::class, 'Invalid parameter');
|
|
|
|
|
|
|
|
|
|
expect($containerCreations)->toBe(1)
|
|
|
|
|
->and($publicationAttempts)->toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('threads publisher can publish video post', 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.mp4',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-123456789',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('post-123456789');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/threads');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher can publish carousel', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems = [];
|
2026-01-18 18:56:51 +00:00
|
|
|
for ($i = 0; $i < 3; $i++) {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems[] = [
|
|
|
|
|
'id' => "test-media-{$i}",
|
2026-01-18 18:56:51 +00:00
|
|
|
'path' => "media/2026-01/test-image-{$i}.jpg",
|
2026-04-15 23:11:36 +00:00
|
|
|
'url' => "https://example.com/media/2026-01/test-image-{$i}.jpg",
|
2026-01-18 18:56:51 +00:00
|
|
|
'mime_type' => 'image/jpeg',
|
2026-04-15 23:11:36 +00:00
|
|
|
'original_filename' => "test-{$i}.jpg",
|
|
|
|
|
];
|
2026-01-18 18:56:51 +00:00
|
|
|
}
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => $mediaItems]);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-123456789',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('post-123456789');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/threads');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-13 15:18:19 +00:00
|
|
|
test('threads publisher waits for the final carousel container before publishing', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-1',
|
|
|
|
|
'path' => 'media/2026-01/test-image-1.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image-1.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test-1.jpg',
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-2',
|
|
|
|
|
'path' => 'media/2026-01/test-image-2.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image-2.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test-2.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$containerCreations = 0;
|
|
|
|
|
$requestOrder = [];
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$containerCreations, &$requestOrder) {
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads')) {
|
|
|
|
|
$containerCreations++;
|
|
|
|
|
$containerId = $containerCreations <= 2
|
|
|
|
|
? "child-{$containerCreations}"
|
|
|
|
|
: 'carousel-final';
|
|
|
|
|
$requestOrder[] = "create:{$containerId}";
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => $containerId], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($request->url(), '/child-')) {
|
|
|
|
|
$requestOrder[] = 'status:child';
|
|
|
|
|
|
|
|
|
|
return Http::response(['status' => 'FINISHED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($request->url(), '/carousel-final')) {
|
|
|
|
|
$requestOrder[] = 'status:carousel-final';
|
|
|
|
|
|
|
|
|
|
return Http::response(['status' => 'FINISHED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads_publish')) {
|
|
|
|
|
$requestOrder[] = 'publish';
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => 'carousel-post'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(['permalink' => 'https://www.threads.net/carousel'], 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($requestOrder)
|
|
|
|
|
->toContain('status:carousel-final')
|
|
|
|
|
->toContain('publish');
|
|
|
|
|
|
|
|
|
|
$finalStatusIndex = array_search('status:carousel-final', $requestOrder, true);
|
|
|
|
|
$publishIndex = array_search('publish', $requestOrder, true);
|
|
|
|
|
|
|
|
|
|
expect($finalStatusIndex)->toBeInt()
|
|
|
|
|
->and($publishIndex)->toBeInt()
|
|
|
|
|
->and($finalStatusIndex)->toBeLessThan($publishIndex);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher recreates the complete carousel after a missing final container', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-1',
|
|
|
|
|
'path' => 'media/2026-01/test-image-1.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image-1.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test-1.jpg',
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-2',
|
|
|
|
|
'path' => 'media/2026-01/test-image-2.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image-2.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test-2.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$childCreations = 0;
|
|
|
|
|
$carouselCreations = 0;
|
|
|
|
|
$publicationAttempts = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$childCreations, &$carouselCreations, &$publicationAttempts) {
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads')) {
|
|
|
|
|
if ($request['media_type'] === 'CAROUSEL') {
|
|
|
|
|
$carouselCreations++;
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => "carousel-{$carouselCreations}"], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$childCreations++;
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => "child-{$childCreations}"], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($request->url(), '/child-') || str_contains($request->url(), '/carousel-')) {
|
|
|
|
|
return Http::response(['status' => 'FINISHED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_ends_with($request->url(), '/123456789/threads_publish')) {
|
|
|
|
|
$publicationAttempts++;
|
|
|
|
|
|
|
|
|
|
if ($publicationAttempts === 1) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'The requested resource does not exist',
|
|
|
|
|
'code' => 24,
|
|
|
|
|
'error_subcode' => 4279009,
|
|
|
|
|
],
|
|
|
|
|
], 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(['id' => 'carousel-post'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(['permalink' => 'https://www.threads.net/carousel'], 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('carousel-post')
|
|
|
|
|
->and($childCreations)->toBe(4)
|
|
|
|
|
->and($carouselCreations)->toBe(2)
|
|
|
|
|
->and($publicationAttempts)->toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('threads publisher throws exception on api error', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'Invalid parameter',
|
|
|
|
|
'type' => 'OAuthException',
|
|
|
|
|
'code' => 100,
|
|
|
|
|
],
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher throws token expired exception on auth error', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'Error validating access token',
|
|
|
|
|
'type' => 'OAuthException',
|
|
|
|
|
'code' => 190,
|
|
|
|
|
],
|
|
|
|
|
], 401),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher refreshes token when expired', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/refresh_access_token*' => Http::response([
|
|
|
|
|
'access_token' => 'new-long-lived-token',
|
|
|
|
|
'expires_in' => 5184000,
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-123456789',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), 'refresh_access_token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->socialAccount->refresh();
|
|
|
|
|
expect($this->socialAccount->access_token)->toBe('new-long-lived-token');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 21:56:52 +00:00
|
|
|
test('threads publisher throws TokenExpiredException when refresh_token is rejected', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/refresh_access_token*' => Http::response([
|
|
|
|
|
'error' => ['message' => 'Token is invalid', 'code' => 190],
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'Token is invalid');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('threads publisher waits for media processing', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$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',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::sequence()
|
|
|
|
|
->push(['status' => 'IN_PROGRESS'], 200)
|
|
|
|
|
->push(['status' => 'IN_PROGRESS'], 200)
|
|
|
|
|
->push(['status' => 'FINISHED'], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-123456789',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('post-123456789');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher handles media processing error', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$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',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'ERROR',
|
|
|
|
|
'error_message' => 'Media upload failed',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Threads media processing failed');
|
|
|
|
|
});
|
2026-03-29 20:04:01 +00:00
|
|
|
|
|
|
|
|
test('threads publisher throws exception for text post with null content', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update(['content' => null]);
|
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, 'Threads text posts require content');
|
2026-03-29 20:04:01 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher can publish image with null content', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'content' => null,
|
|
|
|
|
'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',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-03-29 20:04:01 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'media-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/media-123*' => Http::response([
|
|
|
|
|
'permalink' => 'https://threads.net/@testuser/post/123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('media-123');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
test('threads publisher throws exception when all carousel items fail', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems = [];
|
2026-04-01 12:32:43 +00:00
|
|
|
for ($i = 0; $i < 3; $i++) {
|
2026-04-15 23:11:36 +00:00
|
|
|
$mediaItems[] = [
|
|
|
|
|
'id' => "test-media-{$i}",
|
2026-04-01 12:32:43 +00:00
|
|
|
'path' => "media/2026-01/fail-image-{$i}.jpg",
|
2026-04-15 23:11:36 +00:00
|
|
|
'url' => "https://example.com/media/2026-01/fail-image-{$i}.jpg",
|
2026-04-01 12:32:43 +00:00
|
|
|
'mime_type' => 'image/jpeg',
|
2026-04-15 23:11:36 +00:00
|
|
|
'original_filename' => "fail-{$i}.jpg",
|
|
|
|
|
];
|
2026-04-01 12:32:43 +00:00
|
|
|
}
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => $mediaItems]);
|
2026-04-01 12:32:43 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'error' => [
|
|
|
|
|
'message' => 'Upload failed',
|
|
|
|
|
'type' => 'OAuthException',
|
|
|
|
|
'code' => 100,
|
|
|
|
|
],
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'Failed to create any carousel items');
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 12:25:07 +00:00
|
|
|
test('threads publisher sends capped alt text on single image container', function () {
|
|
|
|
|
$longAlt = str_repeat('a', 1500);
|
|
|
|
|
|
|
|
|
|
$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],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-alt-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-alt-123*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ALT123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
$expectedAlt = mb_substr($longAlt, 0, Platform::Threads->altTextMaxLength());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) use ($expectedAlt) {
|
|
|
|
|
return str_ends_with($request->url(), '/123456789/threads')
|
|
|
|
|
&& data_get($request->data(), 'alt_text') === $expectedAlt
|
|
|
|
|
&& strlen($expectedAlt) === Platform::Threads->altTextMaxLength();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher omits alt_text from single image container 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',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-no-alt-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-no-alt-123*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/NOALT123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_ends_with($request->url(), '/123456789/threads')
|
|
|
|
|
&& ! array_key_exists('alt_text', $request->data());
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('threads publisher sends alt text on image carousel children but never on video children', function () {
|
|
|
|
|
$imageAlt = str_repeat('x', 1500);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-image',
|
|
|
|
|
'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' => $imageAlt],
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'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.mp4',
|
|
|
|
|
'meta' => ['alt_text' => 'video alt text must never be sent'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'post-carousel-alt-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-carousel-alt-123*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/CAROUSELALT123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
$expectedAlt = mb_substr($imageAlt, 0, Platform::Threads->altTextMaxLength());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) use ($expectedAlt) {
|
|
|
|
|
$data = $request->data();
|
|
|
|
|
|
|
|
|
|
return str_ends_with($request->url(), '/123456789/threads')
|
|
|
|
|
&& data_get($data, 'image_url') !== null
|
|
|
|
|
&& data_get($data, 'alt_text') === $expectedAlt;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
$data = $request->data();
|
|
|
|
|
|
|
|
|
|
return str_ends_with($request->url(), '/123456789/threads')
|
|
|
|
|
&& data_get($data, 'video_url') !== null
|
|
|
|
|
&& ! array_key_exists('alt_text', $data);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 20:04:01 +00:00
|
|
|
test('threads publisher can publish video with null content', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'content' => null,
|
|
|
|
|
'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.mp4',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-03-29 20:04:01 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
|
|
|
|
|
'id' => 'container-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/container-123*' => Http::response([
|
|
|
|
|
'status' => 'FINISHED',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
|
|
|
|
|
'id' => 'video-123',
|
|
|
|
|
], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/video-123*' => Http::response([
|
|
|
|
|
'permalink' => 'https://threads.net/@testuser/post/456',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('video-123');
|
|
|
|
|
});
|
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('threads publisher keeps links intact', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
|
|
|
|
|
$this->post->update(['content' => 'New post: https://acme.com/blog']);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads' => Http::response(['id' => 'container-123'], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response(['id' => 'post-123456789'], 200),
|
|
|
|
|
'https://graph.threads.net/v1.0/post-123456789*' => Http::response([
|
|
|
|
|
'permalink' => 'https://www.threads.net/@testuser/post/ABC123',
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/123456789/threads')
|
|
|
|
|
&& ! str_contains($request->url(), 'threads_publish')
|
|
|
|
|
&& data_get($request->data(), 'text') === 'New post: https://acme.com/blog');
|
|
|
|
|
});
|