Publish per-image alt text to LinkedIn images and carousels

This commit is contained in:
Paulo Castellano 2026-07-11 09:08:15 -03:00
parent 65b5007784
commit 941e517b2e
2 changed files with 260 additions and 4 deletions

View file

@ -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) {

View file

@ -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++) {