trypost/tests/Feature/Services/Social/LinkedInPublisherTest.php
Paulo Castellano cf045f2cdb 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 13:55:33 -03:00

1147 lines
39 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
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,
'content' => 'Hello from LinkedIn!',
]);
$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([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
'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([
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(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([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([
'message' => 'Invalid request',
'status' => 400,
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class);
});
test('linkedin publisher throws token expired exception on auth error after retry', function () {
Http::fake([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([
'code' => 'EXPIRED_ACCESS_TOKEN',
'message' => 'The token used in the request has expired',
], 401),
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'error' => 'invalid_grant',
'error_description' => 'The refresh token is invalid',
], 400),
]);
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([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 5184000,
], 200),
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(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');
});
test('linkedin publisher throws TokenExpiredException when refresh_token is rejected', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
Http::fake([
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
'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');
});
test('linkedin publisher handles empty content', function () {
$this->post->update(['content' => '']);
Http::fake([
config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [
'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'] === '';
});
});
test('linkedin publisher can publish post with image', 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: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'])
);
});
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);
});
});
test('linkedin publisher can publish carousel with multiple images', 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',
],
[
'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',
],
],
]);
$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');
// Each multiImage entry must carry the image URN under `id` (LinkedIn rejects `media`).
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) === 3
&& data_get($images, '0.id') === 'urn:li:image:CarouselImageUrn1'
&& ! array_key_exists('media', $images[0]);
});
});
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]);
});
});
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;
});
});
test('linkedin publisher can publish post with video', 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',
],
],
]);
$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'));
});
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);
});
});
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;
});
});
test('linkedin publisher can publish a document (pdf carousel) with a title', function () {
$this->postPlatform->update([
'content_type' => ContentType::LinkedInPost,
'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 () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$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 () {
$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/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 () {
$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/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'));
});
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'));
});
test('linkedin publisher throws and does not post when document init fails', 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',
]],
]);
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 () {
$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',
]],
]);
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))
->toThrow(LinkedInPublishException::class, 'did not accept the document upload');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
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);
});
test('linkedin publisher does NOT rotate the token when it is only expiring soon but still valid', function () {
$this->socialAccount->update([
'token_expires_at' => now()->addMinutes(5),
'refresh_token' => 'refresh-token-123',
]);
$originalAccessToken = $this->socialAccount->access_token;
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');
// 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'));
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe($originalAccessToken);
});
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();
});