From 9a86aba235b2f51d4142c4210b3d34ed555992c2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 11 Jul 2026 09:18:58 -0300 Subject: [PATCH] Publish per-image alt text to Instagram image containers --- app/Services/Social/InstagramPublisher.php | 30 ++++- .../Social/InstagramPublisherTest.php | 118 ++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 176bdcc3..bd751bdd 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -4,7 +4,9 @@ namespace App\Services\Social; +use App\DataTransferObjects\MediaItem; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\Social\SocialPublishException; @@ -81,12 +83,18 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s { $imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio); - // Step 1: Create container - $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ + $params = [ 'image_url' => $imageUrl, 'caption' => $content, 'access_token' => $accessToken, - ]); + ]; + + if ($alt = $this->altFor($media)) { + $params['alt_text'] = $alt; + } + + // Step 1: Create container + $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); if ($containerResponse->failed()) { Log::error('Instagram container creation failed', [ @@ -206,6 +214,10 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $params['media_type'] = 'VIDEO'; } else { $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio); + + if ($alt = $this->altFor($media)) { + $params['alt_text'] = $alt; + } } $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); @@ -355,4 +367,16 @@ private function handleApiError(Response $response): never { throw InstagramPublishException::fromApiResponse($response); } + + /** + * User-provided alt text for the image, capped to Instagram's accepted + * length. Instagram only accepts `alt_text` on image containers/children, + * never on video/reel containers. + */ + private function altFor(MediaItem $media): ?string + { + $alt = $media->altText(); + + return $alt === null ? null : mb_substr($alt, 0, Platform::Instagram->altTextMaxLength()); + } } diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 30ff943b..120f682c 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -912,3 +912,121 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string '1:1' => ['1:1', 1.0], '4:5' => ['4:5', 4 / 5], ]); + +test('instagram publisher sends capped alt text on single image container', function () { + $longAlt = str_repeat('a', 1500); + + $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], + ], + ], + ]); + + Http::fake([ + '*/ig_123456789/media' => Http::response(['id' => 'container-123'], 200), + '*/container-123*' => Http::response(['status_code' => 'FINISHED'], 200), + '*/ig_123456789/media_publish' => Http::response(['id' => 'media-alt-123'], 200), + '*/media-alt-123*' => Http::response(['permalink' => 'https://www.instagram.com/p/ALT123/'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $expectedAlt = mb_substr($longAlt, 0, Platform::Instagram->altTextMaxLength()); + + Http::assertSent(function ($request) use ($expectedAlt) { + return str_ends_with($request->url(), '/ig_123456789/media') + && data_get($request->data(), 'alt_text') === $expectedAlt + && strlen($expectedAlt) === Platform::Instagram->altTextMaxLength(); + }); +}); + +test('instagram publisher omits alt_text from single image container when no alt text 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', + ], + ], + ]); + + Http::fake([ + '*/ig_123456789/media' => Http::response(['id' => 'container-123'], 200), + '*/container-123*' => Http::response(['status_code' => 'FINISHED'], 200), + '*/ig_123456789/media_publish' => Http::response(['id' => 'media-no-alt-123'], 200), + '*/media-no-alt-123*' => Http::response(['permalink' => 'https://www.instagram.com/p/NOALT123/'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_ends_with($request->url(), '/ig_123456789/media') + && ! array_key_exists('alt_text', $request->data()); + }); +}); + +test('instagram publisher sends alt text on image carousel children but never on video children', function () { + $imageAlt = str_repeat('x', 1500); + + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-image', + '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' => $imageAlt], + ], + [ + '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.mp4', + 'meta' => ['alt_text' => 'video alt text must never be sent'], + ], + ], + ]); + + Http::fake([ + '*/ig_123456789/media' => Http::sequence() + ->push(['id' => 'child-1'], 200) + ->push(['id' => 'child-2'], 200) + ->push(['id' => 'carousel-container-123'], 200), + '*/child-2*' => Http::response(['status_code' => 'FINISHED'], 200), + '*/carousel-container-123*' => Http::response(['status_code' => 'FINISHED'], 200), + '*/ig_123456789/media_publish' => Http::response(['id' => 'carousel-alt-123'], 200), + '*/carousel-alt-123*' => Http::response(['permalink' => 'https://www.instagram.com/p/CAROUSELALT/'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $expectedAlt = mb_substr($imageAlt, 0, Platform::Instagram->altTextMaxLength()); + + Http::assertSent(function ($request) use ($expectedAlt) { + $data = $request->data(); + + return str_ends_with($request->url(), '/ig_123456789/media') + && data_get($data, 'image_url') !== null + && data_get($data, 'alt_text') === $expectedAlt; + }); + + Http::assertSent(function ($request) { + $data = $request->data(); + + return str_ends_with($request->url(), '/ig_123456789/media') + && data_get($data, 'video_url') !== null + && ! array_key_exists('alt_text', $data); + }); +});