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;
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
use App\Exceptions\Social\LinkedInPublishException;
|
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\LinkedInPublisher;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->linkedin()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform_user_id' => 'abc123xyz',
|
|
|
|
|
'username' => 'johndoe',
|
|
|
|
|
'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 LinkedIn!',
|
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::LinkedIn,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher = new LinkedInPublisher;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher can publish text-only post', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result)->toHaveKey('id');
|
|
|
|
|
expect($result)->toHaveKey('url');
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:1234567890');
|
|
|
|
|
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:1234567890');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/rest/posts')
|
|
|
|
|
&& $request['author'] === 'urn:li:person:abc123xyz'
|
|
|
|
|
&& $request['commentary'] === 'Hello from LinkedIn!'
|
|
|
|
|
&& $request['visibility'] === 'PUBLIC';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher uses correct headers', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request->hasHeader('Authorization')
|
|
|
|
|
&& $request->hasHeader('X-Restli-Protocol-Version')
|
|
|
|
|
&& $request->hasHeader('LinkedIn-Version')
|
|
|
|
|
&& str_starts_with($request->header('Authorization')[0], 'Bearer ');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher throws exception on api error', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([
|
2026-01-18 18:56:51 +00:00
|
|
|
'message' => 'Invalid request',
|
|
|
|
|
'status' => 400,
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-26 13:48:20 +00:00
|
|
|
test('linkedin publisher throws token expired exception on auth error after retry', function () {
|
2026-01-18 18:56:51 +00:00
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([
|
2026-01-18 18:56:51 +00:00
|
|
|
'code' => 'EXPIRED_ACCESS_TOKEN',
|
|
|
|
|
'message' => 'The token used in the request has expired',
|
|
|
|
|
], 401),
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
2026-01-26 13:48:20 +00:00
|
|
|
'error' => 'invalid_grant',
|
|
|
|
|
'error_description' => 'The refresh token is invalid',
|
|
|
|
|
], 400),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher refreshes token when expired', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
2026-01-18 18:56:51 +00:00
|
|
|
'access_token' => 'new-access-token',
|
|
|
|
|
'refresh_token' => 'new-refresh-token',
|
|
|
|
|
'expires_in' => 5184000,
|
|
|
|
|
], 200),
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), 'oauth/v2/accessToken');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->socialAccount->refresh();
|
|
|
|
|
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin 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 LinkedIn account');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 21:56:52 +00:00
|
|
|
test('linkedin publisher throws TokenExpiredException when refresh_token is rejected', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
2026-05-12 21:56:52 +00:00
|
|
|
'error' => 'invalid_grant',
|
|
|
|
|
'error_description' => 'The refresh token is invalid',
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'The refresh token is invalid');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('linkedin publisher handles empty content', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update(['content' => '']);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:1234567890');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request['commentary'] === '';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
test('linkedin publisher can publish post with image', 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-04-01 12:32:43 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'image' => 'urn:li:image:C5622AQFakeImageUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:9876543210']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:9876543210');
|
|
|
|
|
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:9876543210');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/images'));
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')
|
|
|
|
|
&& isset($request['content']['media']['id'])
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 12:08:15 +00:00
|
|
|
test('linkedin publisher sends the real alt text on a single image post', 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 golden retriever catching a frisbee in the park'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'image' => 'urn:li:image:C5622AQFakeImageUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:altsingle']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data_get($request->data(), 'content.media.altText') === 'A golden retriever catching a frisbee in the park'
|
|
|
|
|
&& data_get($request->data(), 'content.media.altText') !== 'test.jpg';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher omits alt text on a single image post when none 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',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'image' => 'urn:li:image:C5622AQFakeImageUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:altnone']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$media = data_get($request->data(), 'content.media');
|
|
|
|
|
|
|
|
|
|
return is_array($media)
|
|
|
|
|
&& $media['id'] === 'urn:li:image:C5622AQFakeImageUrn'
|
|
|
|
|
&& ! array_key_exists('altText', $media);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher truncates single image alt text to the platform max length', function () {
|
|
|
|
|
$longAlt = str_repeat('a', 4200);
|
|
|
|
|
|
|
|
|
|
$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],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'image' => 'urn:li:image:C5622AQFakeImageUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:alttrunc']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data_get($request->data(), 'content.media.altText') === str_repeat('a', 4086);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
test('linkedin publisher can publish carousel with multiple images', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-1',
|
|
|
|
|
'path' => 'media/2026-01/carousel-1.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/carousel-1.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'carousel-1.jpg',
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-2',
|
|
|
|
|
'path' => 'media/2026-01/carousel-2.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/carousel-2.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'carousel-2.jpg',
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-3',
|
|
|
|
|
'path' => 'media/2026-01/carousel-3.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/carousel-3.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'carousel-3.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
2026-04-01 12:32:43 +00:00
|
|
|
|
|
|
|
|
$uploadUrls = [
|
|
|
|
|
'https://www.linkedin.com/dms/upload/v2/pic/carousel/1',
|
|
|
|
|
'https://www.linkedin.com/dms/upload/v2/pic/carousel/2',
|
|
|
|
|
'https://www.linkedin.com/dms/upload/v2/pic/carousel/3',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$imageUrns = [
|
|
|
|
|
'urn:li:image:CarouselImageUrn1',
|
|
|
|
|
'urn:li:image:CarouselImageUrn2',
|
|
|
|
|
'urn:li:image:CarouselImageUrn3',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$initCallCount = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrls, $imageUrns, &$initCallCount) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
$idx = $initCallCount % 3;
|
|
|
|
|
$initCallCount++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrls[$idx],
|
|
|
|
|
'image' => $imageUrns[$idx],
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Image PUT upload
|
|
|
|
|
foreach ($uploadUrls as $uploadUrl) {
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:carousel999']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:carousel999');
|
|
|
|
|
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:carousel999');
|
|
|
|
|
|
refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 13:17:02 +00:00
|
|
|
// Each multiImage entry must carry the image URN under `id` (LinkedIn rejects `media`).
|
2026-04-01 12:32:43 +00:00
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$images = data_get($request->data(), 'content.multiImage.images');
|
|
|
|
|
|
refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 13:17:02 +00:00
|
|
|
return is_array($images)
|
|
|
|
|
&& count($images) === 3
|
|
|
|
|
&& data_get($images, '0.id') === 'urn:li:image:CarouselImageUrn1'
|
|
|
|
|
&& ! array_key_exists('media', $images[0]);
|
2026-04-01 12:32:43 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 12:08:15 +00:00
|
|
|
test('linkedin publisher sends per-image alt text on a carousel, not the filename', function () {
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-1',
|
|
|
|
|
'path' => 'media/2026-01/carousel-1.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/carousel-1.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'carousel-1.jpg',
|
|
|
|
|
'meta' => ['alt_text' => 'A sunset over the mountains'],
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-2',
|
|
|
|
|
'path' => 'media/2026-01/carousel-2.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/carousel-2.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'carousel-2.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrls = [
|
|
|
|
|
'https://www.linkedin.com/dms/upload/v2/pic/carousel/alt/1',
|
|
|
|
|
'https://www.linkedin.com/dms/upload/v2/pic/carousel/alt/2',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$imageUrns = [
|
|
|
|
|
'urn:li:image:CarouselAltUrn1',
|
|
|
|
|
'urn:li:image:CarouselAltUrn2',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$initCallCount = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrls, $imageUrns, &$initCallCount) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
$idx = $initCallCount % 2;
|
|
|
|
|
$initCallCount++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrls[$idx],
|
|
|
|
|
'image' => $imageUrns[$idx],
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($uploadUrls as $uploadUrl) {
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:carouselalt']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
// The first image carries the user's real alt text (not the filename); the
|
|
|
|
|
// second has no alt_text set, so its `altText` key is omitted entirely.
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$images = data_get($request->data(), 'content.multiImage.images');
|
|
|
|
|
|
|
|
|
|
return is_array($images)
|
|
|
|
|
&& count($images) === 2
|
|
|
|
|
&& data_get($images, '0.altText') === 'A sunset over the mountains'
|
|
|
|
|
&& ! array_key_exists('altText', $images[1]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-25 18:34:03 +00:00
|
|
|
test('linkedin publisher caps a carousel at the platform max images', function () {
|
|
|
|
|
$media = [];
|
|
|
|
|
for ($i = 1; $i <= 12; $i++) {
|
|
|
|
|
$media[] = [
|
|
|
|
|
'id' => "test-media-{$i}",
|
|
|
|
|
'path' => "media/2026-01/carousel-{$i}.jpg",
|
|
|
|
|
'url' => "https://example.com/media/2026-01/carousel-{$i}.jpg",
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => "carousel-{$i}.jpg",
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
$this->post->update(['media' => $media]);
|
|
|
|
|
|
|
|
|
|
$initCallCount = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use (&$initCallCount) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
$initCallCount++;
|
|
|
|
|
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => "https://www.linkedin.com/dms/upload/v2/pic/carousel/{$initCallCount}",
|
|
|
|
|
'image' => "urn:li:image:CarouselImageUrn{$initCallCount}",
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/dms/upload/v2/pic/carousel/')) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:capped']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
// Only the first 10 images (LinkedIn::maxImages) are uploaded; the extra 2 are dropped.
|
|
|
|
|
expect($initCallCount)->toBe(10);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return count(data_get($request->data(), 'content.multiImage.images', [])) === 10;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 12:32:43 +00:00
|
|
|
test('linkedin publisher can publish post with video', 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
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$chunkUploadUrl = 'https://www.linkedin.com/dms/upload/v2/chunk/video/1';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($chunkUploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'video' => 'urn:li:video:FakeVideoUrn',
|
|
|
|
|
'uploadToken' => 'upload-token-abc',
|
|
|
|
|
'uploadInstructions' => [
|
|
|
|
|
[
|
|
|
|
|
'uploadUrl' => $chunkUploadUrl,
|
|
|
|
|
'firstByte' => 0,
|
|
|
|
|
'lastByte' => 1023,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $chunkUploadUrl) {
|
|
|
|
|
return Http::response(null, 200, ['etag' => '"etag-abc123"']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) {
|
|
|
|
|
return Http::response(null, 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/videos/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:1111111111']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response(str_repeat('x', 1024), 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:1111111111');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'initializeUpload'));
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), 'finalizeUpload'));
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
|
|
|
|
|
});
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
|
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('linkedin publisher never sends altText on a single video post even if the video carries alt text', 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',
|
|
|
|
|
'meta' => ['alt_text' => 'alt text must not be sent on a video payload'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$chunkUploadUrl = 'https://www.linkedin.com/dms/upload/v2/chunk/video/1';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($chunkUploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'video' => 'urn:li:video:FakeVideoUrn',
|
|
|
|
|
'uploadToken' => 'upload-token-abc',
|
|
|
|
|
'uploadInstructions' => [
|
|
|
|
|
['uploadUrl' => $chunkUploadUrl, 'firstByte' => 0, 'lastByte' => 1023],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $chunkUploadUrl) {
|
|
|
|
|
return Http::response(null, 200, ['etag' => '"etag-abc123"']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) {
|
|
|
|
|
return Http::response(null, 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/videos/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:videonoalt']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(str_repeat('x', 1024), 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$media = data_get($request->data(), 'content.media');
|
|
|
|
|
|
|
|
|
|
return is_array($media)
|
|
|
|
|
&& data_get($media, 'id') === 'urn:li:video:FakeVideoUrn'
|
|
|
|
|
&& ! array_key_exists('altText', $media);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-26 00:38:52 +00:00
|
|
|
test('linkedin publisher uploads a video across multiple chunks', 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',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$chunkBase = 'https://www.linkedin.com/dms/upload/v2/chunk/video/';
|
|
|
|
|
$chunkPuts = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($chunkBase, &$chunkPuts) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'video' => 'urn:li:video:FakeVideoUrn',
|
|
|
|
|
'uploadToken' => 'upload-token-abc',
|
|
|
|
|
'uploadInstructions' => [
|
|
|
|
|
['uploadUrl' => $chunkBase.'0', 'firstByte' => 0, 'lastByte' => 1023],
|
|
|
|
|
['uploadUrl' => $chunkBase.'1', 'firstByte' => 1024, 'lastByte' => 2047],
|
|
|
|
|
['uploadUrl' => $chunkBase.'2', 'firstByte' => 2048, 'lastByte' => 3071],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_starts_with($url, $chunkBase)) {
|
|
|
|
|
$chunkPuts++;
|
|
|
|
|
|
|
|
|
|
return Http::response(null, 200, ['etag' => '"etag-'.$chunkPuts.'"']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) {
|
|
|
|
|
return Http::response(null, 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/videos/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:multichunk']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response(str_repeat('x', 4096), 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:multichunk');
|
|
|
|
|
expect($chunkPuts)->toBe(3);
|
|
|
|
|
|
|
|
|
|
// All three chunk etags are collected and sent to finalize.
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), 'finalizeUpload')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return count(data_get($request->data(), 'finalizeUploadRequest.uploadedPartIds', [])) === 3;
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
test('linkedin publisher can publish a document (pdf carousel) with a title', function () {
|
|
|
|
|
$this->postPlatform->update([
|
2026-06-25 00:05:09 +00:00
|
|
|
'content_type' => ContentType::LinkedInPost,
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
'meta' => ['document_title' => 'My Slides'],
|
|
|
|
|
]);
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'doc-media-1',
|
|
|
|
|
'path' => 'media/2026-01/deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf',
|
|
|
|
|
'original_filename' => 'deck.pdf',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/0';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'document' => 'urn:li:document:FakeDocUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:doc999']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:doc999');
|
|
|
|
|
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:doc999');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload'));
|
|
|
|
|
Http::assertSent(function ($request) use ($uploadUrl) {
|
|
|
|
|
return $request->url() === $uploadUrl
|
|
|
|
|
&& $request->hasHeader('Content-Type', 'application/pdf');
|
|
|
|
|
});
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data_get($request->data(), 'content.media.id') === 'urn:li:document:FakeDocUrn'
|
|
|
|
|
&& data_get($request->data(), 'content.media.title') === 'My Slides';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher document title falls back to the file name', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'doc-media-1',
|
|
|
|
|
'path' => 'media/2026-01/whitepaper.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/whitepaper.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf',
|
|
|
|
|
'original_filename' => 'whitepaper.pdf',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/1';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Doc2']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:doc2']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data_get($request->data(), 'content.media.title') === 'whitepaper.pdf';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher waits for document processing before posting', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'doc-media-1',
|
|
|
|
|
'path' => 'media/2026-01/deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf',
|
|
|
|
|
'original_filename' => 'deck.pdf',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/2';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Doc3']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:doc3']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents/urn'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher throws and does not post when document processing fails', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'doc-media-1',
|
|
|
|
|
'path' => 'media/2026-01/deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf',
|
|
|
|
|
'original_filename' => 'deck.pdf',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/fail';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Fail']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'PROCESSING_FAILED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'LinkedIn document processing failed');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
|
|
|
|
|
});
|
2026-06-24 20:36:39 +00:00
|
|
|
|
2026-06-25 18:34:03 +00:00
|
|
|
test('linkedin publisher throws and does not post when document processing never completes', function () {
|
|
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/timeout';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:Timeout']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'PROCESSING'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$publisher = new class extends LinkedInPublisher
|
|
|
|
|
{
|
|
|
|
|
protected function processingMaxAttempts(): int
|
|
|
|
|
{
|
|
|
|
|
return 2;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function processingPollSeconds(): int
|
|
|
|
|
{
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expect(fn () => $publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'processing did not complete in time');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-24 20:36:39 +00:00
|
|
|
test('linkedin publisher throws and does not post when document init fails', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
2026-06-24 20:36:39 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) {
|
|
|
|
|
if (str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload')) {
|
|
|
|
|
return Http::response(['message' => 'Invalid request', 'status' => 400], 400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(Exception::class);
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher throws when document init response is missing the urn', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
|
2026-06-24 20:36:39 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) {
|
|
|
|
|
if (str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload')) {
|
|
|
|
|
return Http::response(['value' => []], 200); // no uploadUrl / document
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.
Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).
Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 23:16:16 +00:00
|
|
|
->toThrow(LinkedInPublishException::class, 'did not accept the document upload');
|
2026-06-24 20:36:39 +00:00
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
|
|
|
|
|
});
|
2026-06-25 00:05:09 +00:00
|
|
|
|
|
|
|
|
test('linkedin publisher treats a 401 response without an error code as a token error', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(['message' => 'Unauthorized'], 401),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-03 11:30:58 +00:00
|
|
|
test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->socialAccount->update([
|
|
|
|
|
'token_expires_at' => now()->addMinutes(5),
|
|
|
|
|
'refresh_token' => 'refresh-token-123',
|
|
|
|
|
]);
|
2026-07-03 11:30:58 +00:00
|
|
|
$originalAccessToken = $this->socialAccount->access_token;
|
2026-06-25 00:05:09 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
|
|
|
|
'access_token' => 'new-access-token',
|
|
|
|
|
'refresh_token' => 'new-refresh-token',
|
|
|
|
|
'expires_in' => 3600,
|
|
|
|
|
], 200),
|
|
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:soon']),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:soon');
|
2026-07-03 11:30:58 +00:00
|
|
|
|
|
|
|
|
// A still-valid token is used as-is — the single-use refresh_token is not rotated.
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'oauth/v2/accessToken'));
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->socialAccount->refresh();
|
2026-07-03 11:30:58 +00:00
|
|
|
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
|
2026-06-25 00:05:09 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('');
|
|
|
|
|
expect($result['url'])->toBeNull();
|
|
|
|
|
});
|
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('linkedin publisher keeps links intact', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
|
|
|
|
|
$this->post->update(['content' => 'New post: https://trypost.it/blog']);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
|
|
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')
|
|
|
|
|
&& $request['commentary'] === 'New post: https://trypost.it/blog');
|
|
|
|
|
});
|