Publish per-image alt text to Instagram image containers

This commit is contained in:
Paulo Castellano 2026-07-11 09:18:58 -03:00
parent 2c81416bd0
commit 9a86aba235
2 changed files with 145 additions and 3 deletions

View file

@ -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());
}
}

View file

@ -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);
});
});