Extract alt-text truncation onto MediaItem and close test gaps

Centralize the per-platform mb_substr truncation that every publisher was
repeating into MediaItem::altTextFor(Platform), delete each publisher's
private altFor() helper, and clarify the Platform::altTextMaxLength()
docblock so it doesn't imply Instagram's documented 1000-char cap is a
guess. Add the assertions review flagged as missing: LinkedInPage/
InstagramFacebook alt-text caps, non-string and literal-"0" alt_text
normalization, altTextFor() truncation/unsupported-platform behavior,
Mastodon's no-description-part case, and the public API's accept/reject
path for media.*.meta.alt_text.
This commit is contained in:
Paulo Castellano 2026-07-11 10:34:57 -03:00
parent 0e93d7e5e3
commit f74053b118
15 changed files with 196 additions and 85 deletions

View file

@ -6,6 +6,7 @@
use App\Enums\Media\Source;
use App\Enums\Media\Type;
use App\Enums\SocialAccount\Platform;
class MediaItem
{
@ -75,6 +76,23 @@ public function altText(): ?string
return $alt === '' ? null : $alt;
}
/**
* The alt text truncated to the given platform's cap, or null when no alt
* text is set or the platform doesn't support it. Single place that applies
* the per-platform cap so publishers don't each repeat the truncation.
*/
public function altTextFor(Platform $platform): ?string
{
$alt = $this->altText();
$max = $platform->altTextMaxLength();
if ($alt === null || $max === null) {
return null;
}
return mb_substr($alt, 0, $max);
}
/**
* @param array<string, mixed> $data
*/

View file

@ -129,9 +129,11 @@ public function maxImages(): int
/**
* Character cap the platform's API accepts for image alt text (accessibility
* description), or null when the platform has no alt-text field. Facebook and
* Threads document no limit, so a defensive 1000 is used. Single source of
* truth publishers truncate to this value, never a literal.
* description), or null when the platform has no alt-text field. X, LinkedIn,
* Instagram, Pinterest, and Discord use documented API maxes. Facebook,
* Threads, Mastodon, and Bluesky document no limit, so a defensive cap is
* used instead. Single source of truth publishers truncate to this value,
* never a literal.
*/
public function altTextMaxLength(): ?int
{

View file

@ -4,7 +4,6 @@
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;
@ -127,7 +126,7 @@ private function publishPost(?string $content, $media): array
if ($mediaUrn) {
$payload['content'] = ['media' => array_filter([
'id' => $mediaUrn,
'altText' => $this->altFor($media->first()),
'altText' => $media->first()->altTextFor($this->platform()),
], fn ($v) => $v !== null)];
}
}
@ -149,7 +148,7 @@ private function publishCarousel(?string $content, $media): array
if ($imageUrn) {
$images[] = array_filter([
'id' => $imageUrn,
'altText' => $this->altFor($item),
'altText' => $item->altTextFor($this->platform()),
], fn ($v) => $v !== null);
}
}
@ -247,18 +246,6 @@ 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) {

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
@ -64,7 +63,7 @@ public function publish(PostPlatform $postPlatform): array
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
if ($blob) {
$images[] = [
'alt' => $this->altFor($media),
'alt' => $media->altTextFor(Platform::Bluesky) ?? '',
'image' => $blob,
];
}
@ -702,21 +701,6 @@ private function buildPostUrl(string $handle, string $postId): string
return "{$webApp}/profile/{$handle}/post/{$postId}";
}
/**
* Bluesky requires `alt` on every image, so this returns '' (never null)
* when the user set no description, and the capped description otherwise.
*/
private function altFor(MediaItem $media): string
{
$alt = $media->altText();
if ($alt === null) {
return '';
}
return mb_substr($alt, 0, Platform::Bluesky->altTextMaxLength());
}
private function handleApiError(Response $response): never
{
throw BlueskyPublishException::fromApiResponse($response);

View file

@ -129,10 +129,10 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me
$attachment = ['id' => $index, 'filename' => $filename];
$alt = $item->altText();
$alt = $item->altTextFor(Platform::Discord);
if ($alt !== null) {
$attachment['description'] = mb_substr($alt, 0, Platform::Discord->altTextMaxLength());
$attachment['description'] = $alt;
}
$attachments[] = $attachment;

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
@ -136,7 +135,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['message'] = $content;
}
$alt = $this->altFor($media);
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$payload['alt_text_custom'] = $alt;
@ -177,7 +176,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
'access_token' => $accessToken,
];
$alt = $this->altFor($media);
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$uploadPayload['alt_text_custom'] = $alt;
@ -428,16 +427,6 @@ 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(

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
@ -89,7 +88,7 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
'access_token' => $accessToken,
];
$alt = $this->altFor($media);
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
@ -217,7 +216,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
} else {
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio);
$alt = $this->altFor($media);
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
@ -371,16 +370,4 @@ 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

@ -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, $media->altText());
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altTextFor(Platform::Mastodon));
if ($mediaId) {
$mediaIds[] = $mediaId;
}
@ -105,7 +105,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
->attach('file', $stream, $name);
if ($altText !== null) {
$request = $request->attach('description', mb_substr($altText, 0, Platform::Mastodon->altTextMaxLength()));
$request = $request->attach('description', $altText);
}
$response = $request->post("{$instance}/api/v1/media");

View file

@ -119,10 +119,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
$payload['link'] = data_get($postPlatform->meta, 'link');
}
$alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altText();
$alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest);
if ($alt !== null) {
$payload['alt_text'] = mb_substr($alt, 0, Platform::Pinterest->altTextMaxLength());
$payload['alt_text'] = $alt;
}
$response = $this->socialHttp()->withToken($account->access_token)

View file

@ -4,7 +4,6 @@
namespace App\Services\Social;
use App\DataTransferObjects\MediaItem;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\ThreadsPublishException;
@ -109,7 +108,7 @@ private function publishImagePost(string $userId, string $accessToken, ?string $
'access_token' => $accessToken,
];
$alt = $this->altFor($media);
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
@ -195,7 +194,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
$params['media_type'] = 'IMAGE';
$params['image_url'] = $media->url;
$alt = $this->altFor($media);
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
@ -347,15 +346,4 @@ private function handleApiError(Response $response): never
{
throw ThreadsPublishException::fromApiResponse($response);
}
/**
* User-provided alt text for the image, capped to Threads' accepted
* length. Scoped to image containers/children only.
*/
private function altFor(MediaItem $media): ?string
{
$alt = $media->altText();
return $alt === null ? null : mb_substr($alt, 0, Platform::Threads->altTextMaxLength());
}
}

View file

@ -114,7 +114,7 @@ private function getHttpClient(): PendingRequest
*/
private function uploadAltText(string $mediaId, MediaItem $mediaItem): void
{
$alt = $mediaItem->altText();
$alt = $mediaItem->altTextFor(Platform::X);
if ($alt === null) {
return;
@ -124,7 +124,7 @@ private function uploadAltText(string $mediaId, MediaItem $mediaItem): void
'id' => $mediaId,
'metadata' => [
'alt_text' => [
'text' => mb_substr($alt, 0, Platform::X->altTextMaxLength()),
'text' => $alt,
],
],
]);

View file

@ -446,3 +446,69 @@
expect($this->post->fresh()->media)->toBe($original);
});
it('accepts and persists media alt text on create', function () {
$this->socialAccount->update(['is_active' => true]);
Http::preventStrayRequests();
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Alt text post',
'media' => [[
'id' => 'media-1',
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
'meta' => ['alt_text' => 'A description of the photo'],
]],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertCreated();
$media = Post::where('content', 'Alt text post')->firstOrFail()->media;
expect(data_get($media, '0.meta.alt_text'))->toBe('A description of the photo');
});
it('accepts and persists media alt text on update', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $this->post), [
'status' => 'draft',
'media' => [[
'id' => 'media-1',
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
'meta' => ['alt_text' => 'Updated alt text'],
]],
])
->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('Updated alt text');
});
it('rejects media alt text over 2000 characters', function () {
$this->socialAccount->update(['is_active' => true]);
Http::preventStrayRequests();
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'Alt text too long post',
'media' => [[
'id' => 'media-1',
'path' => 'assets/foo.jpg',
'url' => 'https://cdn.trypost.test/assets/foo.jpg',
'type' => 'image',
'meta' => ['alt_text' => str_repeat('a', 2001)],
]],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media.0.meta.alt_text']);
expect(Post::where('content', 'Alt text too long post')->exists())->toBeFalse();
});

View file

@ -196,6 +196,71 @@
@unlink($optimizedFile);
});
test('mastodon publisher sends no description part when image has no alt text', 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_no_alt_opt_');
file_put_contents($optimizedFile, str_repeat('x', 1024));
$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',
],
],
]);
$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-no-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);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/api/v1/media')) {
return false;
}
return collect($request->data())->firstWhere('name', 'description') === null;
});
@unlink($optimizedFile);
});
test('mastodon publisher includes media ids in post', function () {
Http::fake([
'https://mastodon.social/api/v1/statuses' => Http::response([

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\DataTransferObjects\MediaItem;
use App\Enums\SocialAccount\Platform;
test('altText returns the trimmed meta alt_text', function () {
$item = MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => ' a cat ']]);
@ -14,3 +15,23 @@
expect(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u'])->altText())->toBeNull()
->and(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => ' ']])->altText())->toBeNull();
});
test('altText is null when meta alt_text is not a string', function () {
expect(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => 123]])->altText())->toBeNull()
->and(MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => ['x']]])->altText())->toBeNull();
});
test('altText keeps the literal string "0"', function () {
$item = MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => '0']]);
expect($item->altText())->toBe('0');
});
test('altTextFor truncates to the platform cap and is null for an unsupported platform', function () {
$longAlt = str_repeat('a', Platform::X->altTextMaxLength() + 50);
$item = MediaItem::fromArray(['id' => 'a', 'path' => 'p.jpg', 'url' => 'u', 'meta' => ['alt_text' => $longAlt]]);
expect($item->altTextFor(Platform::X))->toBe(mb_substr($longAlt, 0, Platform::X->altTextMaxLength()))
->and(mb_strlen($item->altTextFor(Platform::X)))->toBe(Platform::X->altTextMaxLength())
->and($item->altTextFor(Platform::TikTok))->toBeNull();
});

View file

@ -9,7 +9,9 @@
->and(Platform::X->altTextMaxLength())->toBe(1000)
->and(Platform::Mastodon->altTextMaxLength())->toBe(1500)
->and(Platform::LinkedIn->altTextMaxLength())->toBe(4086)
->and(Platform::LinkedInPage->altTextMaxLength())->toBe(4086)
->and(Platform::Instagram->altTextMaxLength())->toBe(1000)
->and(Platform::InstagramFacebook->altTextMaxLength())->toBe(1000)
->and(Platform::Pinterest->altTextMaxLength())->toBe(500)
->and(Platform::Discord->altTextMaxLength())->toBe(1024)
->and(Platform::Facebook->altTextMaxLength())->toBe(1000)
@ -24,5 +26,7 @@
test('supportsAltText mirrors altTextMaxLength', function () {
expect(Platform::Bluesky->supportsAltText())->toBeTrue()
->and(Platform::LinkedInPage->supportsAltText())->toBeTrue()
->and(Platform::InstagramFacebook->supportsAltText())->toBeTrue()
->and(Platform::TikTok->supportsAltText())->toBeFalse();
});