diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 05a6eba3..8edc58b7 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array // Upload media first (max 4) foreach ($medias->take(4) as $media) { - $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename); + $mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altText()); if ($mediaId) { $mediaIds[] = $mediaId; } @@ -68,7 +68,7 @@ public function publish(PostPlatform $postPlatform): array ]; } - private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename): ?string + private function uploadMedia(SocialAccount $account, string $instance, string $url, ?string $filename, ?string $altText): ?string { $tempFile = tempnam(sys_get_temp_dir(), 'masto_media_'); @@ -101,9 +101,14 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u $stream = fopen($tempFile, 'r'); - $response = $this->socialHttp()->withToken($account->access_token) - ->attach('file', $stream, $name) - ->post("{$instance}/api/v1/media"); + $request = $this->socialHttp()->withToken($account->access_token) + ->attach('file', $stream, $name); + + if ($altText !== null) { + $request = $request->attach('description', mb_substr($altText, 0, Platform::Mastodon->altTextMaxLength())); + } + + $response = $request->post("{$instance}/api/v1/media"); if (is_resource($stream)) { fclose($stream); diff --git a/tests/Feature/Services/Social/MastodonPublisherTest.php b/tests/Feature/Services/Social/MastodonPublisherTest.php index d0aced02..aee4120c 100644 --- a/tests/Feature/Services/Social/MastodonPublisherTest.php +++ b/tests/Feature/Services/Social/MastodonPublisherTest.php @@ -122,6 +122,80 @@ }); }); +test('mastodon publisher sends capped alt text as media description', function () { + // Minimal 1x1 JPEG bytes so mime_content_type() detects image/jpeg + $minimalJpeg = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + ."\xFF\xDB\x00\x43\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\x09\x09\x08\x0A\x0C" + ."\x14\x0D\x0C\x0B\x0B\x0C\x19\x12\x13\x0F\x14\x1D\x1A\x1F\x1E\x1D\x1A\x1C\x1C\x20" + ."\xFF\xC0\x00\x0B\x08\x00\x01\x00\x01\x01\x01\x11\x00\xFF\xC4\x00\x1F\x00\x00\x01" + ."\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05" + ."\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00\xFB\xD3\xFF\xD9"; + + $optimizedFile = tempnam(sys_get_temp_dir(), 'masto_alt_opt_'); + file_put_contents($optimizedFile, str_repeat('x', 1024)); + + $longAlt = str_repeat('a', 2000); + + $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], + ], + ], + ]); + + $this->mock(MediaOptimizer::class) + ->shouldReceive('optimizeImage') + ->once() + ->with(Mockery::any(), Platform::Mastodon) + ->andReturn($optimizedFile); + + Http::fake(function ($request) use ($minimalJpeg) { + $url = $request->url(); + + if (str_contains($url, '/api/v1/media')) { + return Http::response([ + 'id' => 'media-alt-123', + 'type' => 'image', + 'url' => 'https://mastodon.social/media/image.jpg', + ], 200); + } + + if (str_contains($url, '/api/v1/statuses')) { + return Http::response([ + 'id' => '109876543210', + 'url' => 'https://mastodon.social/@testuser/109876543210', + ], 200); + } + + // Media download: return valid JPEG so mime_content_type() detects image/jpeg + return Http::response($minimalJpeg, 200, ['Content-Type' => 'image/jpeg']); + }); + + $this->publisher->publish($this->postPlatform); + + $expectedDescription = mb_substr($longAlt, 0, Platform::Mastodon->altTextMaxLength()); + + Http::assertSent(function ($request) use ($expectedDescription) { + if (! str_contains($request->url(), '/api/v1/media')) { + return false; + } + + $description = collect($request->data())->firstWhere('name', 'description'); + + return $description !== null + && $description['contents'] === $expectedDescription + && mb_strlen($description['contents']) === 1500; + }); + + @unlink($optimizedFile); +}); + test('mastodon publisher includes media ids in post', function () { Http::fake([ 'https://mastodon.social/api/v1/statuses' => Http::response([