Publish per-image alt text to LinkedIn images and carousels
This commit is contained in:
parent
65b5007784
commit
941e517b2e
2 changed files with 260 additions and 4 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue