diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index febc7858..6922e876 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; @@ -124,7 +125,10 @@ private function publishPost(?string $content, $media): array $mediaUrn = $this->uploadMedia($media->first()); if ($mediaUrn) { - $payload['content'] = ['media' => ['id' => $mediaUrn]]; + $payload['content'] = ['media' => array_filter([ + 'id' => $mediaUrn, + 'altText' => $this->altFor($media->first()), + ], fn ($v) => $v !== null)]; } } @@ -143,10 +147,10 @@ private function publishCarousel(?string $content, $media): array $imageUrn = $this->uploadImage($item); if ($imageUrn) { - $images[] = [ + $images[] = array_filter([ 'id' => $imageUrn, - 'altText' => $item->original_filename ?? 'Carousel image', - ]; + 'altText' => $this->altFor($item), + ], fn ($v) => $v !== null); } } @@ -243,6 +247,18 @@ private function resolveDocumentTitle(PostPlatform $postPlatform): string return $postPlatform->post->mediaItems->first(fn ($media) => $media->isDocument())?->original_filename ?? 'Document'; } + /** + * The accessibility description to send for an uploaded image, capped to + * what the platform accepts. Null when the user hasn't set one, so the + * caller can omit the `altText` key entirely rather than sending it empty. + */ + private function altFor(MediaItem $media): ?string + { + $alt = $media->altText(); + + return $alt === null ? null : mb_substr($alt, 0, $this->platform()->altTextMaxLength()); + } + private function uploadMedia($mediaItem): ?string { return match (true) { diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index a08c6899..9e90ea14 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -224,6 +224,165 @@ ); }); +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([ @@ -315,6 +474,87 @@ }); }); +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++) {