diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 0a64cec4..6f351b9f 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.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\FacebookPublishException; use App\Exceptions\Social\SocialPublishException; @@ -134,6 +136,10 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st $payload['message'] = $content; } + if ($alt = $this->altFor($media)) { + $payload['alt_text_custom'] = $alt; + } + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload); if ($response->failed()) { @@ -163,11 +169,17 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str continue; } - $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ + $uploadPayload = [ 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'published' => 'false', 'access_token' => $accessToken, - ]); + ]; + + if ($alt = $this->altFor($media)) { + $uploadPayload['alt_text_custom'] = $alt; + } + + $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload); if ($uploadResponse->failed()) { Log::error('Facebook image upload failed', [ @@ -412,6 +424,16 @@ private function handleApiError(Response $response): never throw FacebookPublishException::fromApiResponse($response); } + /** + * User-provided alt text for the image, capped to Facebook's accepted length. + */ + private function altFor(MediaItem $media): ?string + { + $alt = $media->altText(); + + return $alt === null ? null : mb_substr($alt, 0, Platform::Facebook->altTextMaxLength()); + } + protected function cropFailureException(string $message): SocialPublishException { return new FacebookPublishException( diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index a5e8e37e..fe711086 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -807,3 +807,107 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') && $request['url'] === 'https://example.com/media/a.jpg'); }); + +test('facebook publisher sends capped alt text on single image post', function () { + $longAlt = str_repeat('a', 1500); + + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', + 'meta' => ['alt_text' => $longAlt], + ], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $expectedAlt = mb_substr($longAlt, 0, Platform::Facebook->altTextMaxLength()); + + Http::assertSent(function ($request) use ($expectedAlt) { + return str_contains($request->url(), '/page_123/photos') + && data_get($request->data(), 'alt_text_custom') === $expectedAlt + && strlen($expectedAlt) === Platform::Facebook->altTextMaxLength(); + }); +}); + +test('facebook publisher sends capped alt text for each image in multi image post', function () { + $longAlt1 = str_repeat('b', 1200); + $longAlt2 = str_repeat('c', 1200); + + $this->post->update([ + 'media' => [ + [ + 'id' => 'm1', + 'path' => 'media/2026-01/image1.jpg', + 'url' => 'https://example.com/media/2026-01/image1.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image1.jpg', + 'meta' => ['alt_text' => $longAlt1], + ], + [ + 'id' => 'm2', + 'path' => 'media/2026-01/image2.jpg', + 'url' => 'https://example.com/media/2026-01/image2.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image2.jpg', + 'meta' => ['alt_text' => $longAlt2], + ], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::sequence() + ->push(['id' => 'photo_1'], 200) + ->push(['id' => 'photo_2'], 200), + '*/page_123/feed' => Http::response(['id' => 'multi_post_alt'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + $expectedAlt1 = mb_substr($longAlt1, 0, Platform::Facebook->altTextMaxLength()); + $expectedAlt2 = mb_substr($longAlt2, 0, Platform::Facebook->altTextMaxLength()); + + Http::assertSent(function ($request) use ($expectedAlt1) { + return str_contains($request->url(), '/page_123/photos') + && data_get($request->data(), 'alt_text_custom') === $expectedAlt1; + }); + + Http::assertSent(function ($request) use ($expectedAlt2) { + return str_contains($request->url(), '/page_123/photos') + && data_get($request->data(), 'alt_text_custom') === $expectedAlt2; + }); +}); + +test('facebook publisher omits alt_text_custom from photos payload when no alt text is set', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', + ], + ], + ]); + + Http::fake([ + '*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/page_123/photos') + && ! array_key_exists('alt_text_custom', $request->data()); + }); +});