Merge pull request #179 from trypostit/feat/media-alt-text

Add per-image alt text for accessibility across 9 platforms
This commit is contained in:
Paulo Castellano 2026-07-16 16:12:16 -03:00 committed by GitHub
commit 3e8b351872
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2722 additions and 54 deletions

View file

@ -6,6 +6,7 @@
use App\Enums\Media\Source;
use App\Enums\Media\Type;
use App\Enums\SocialAccount\Platform;
class MediaItem
{
@ -59,6 +60,39 @@ public function height(): ?int
return is_numeric($height) ? (int) $height : null;
}
/**
* User-provided accessibility description for this image, when set.
*/
public function altText(): ?string
{
$alt = data_get($this->meta, 'alt_text');
if (! is_string($alt)) {
return null;
}
$alt = trim($alt);
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

@ -127,6 +127,39 @@ 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. 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
{
return match ($this) {
self::Bluesky => 2000,
self::X => 1000,
self::Mastodon => 1500,
self::LinkedIn, self::LinkedInPage => 4086,
self::Facebook => 1000,
self::Instagram, self::InstagramFacebook => 1000,
self::Threads => 1000,
self::Pinterest => 500,
self::Discord => 1024,
self::TikTok, self::YouTube, self::Telegram => null,
};
}
/**
* Whether the platform's API accepts image alt text (accessibility
* description) on published media.
*/
public function supportsAltText(): bool
{
return $this->altTextMaxLength() !== null;
}
/**
* Hard cap (in characters) the platform's API will accept. Going over this
* means the post can't be published. Values are the documented API maxes:

View file

@ -4,6 +4,7 @@
namespace App\Http\Requests\Api\Post;
use App\Support\PostMediaRules;
use Illuminate\Foundation\Http\FormRequest;
class AttachMediaFromUrlRequest extends FormRequest
@ -20,7 +21,8 @@ public function rules(): array
{
return [
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
];
}
}

View file

@ -282,6 +282,10 @@ private function replaceMediaOnPost(
throw new RuntimeException('Media item changed before regeneration completed.');
}
if (($meta = data_get($items->get($currentIndex), 'meta')) !== null) {
$newMediaItem['meta'] = $meta;
}
$items->put($currentIndex, $newMediaItem);
$fresh->update(['media' => $items->values()->all()]);

View file

@ -8,6 +8,7 @@
use App\Models\Media;
use App\Models\Post;
use App\Models\Workspace;
use App\Support\PostMediaRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -23,6 +24,7 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'upload_token' => ['required', 'uuid'],
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
]);
$workspaceId = $request->user()->current_workspace_id;
@ -48,14 +50,20 @@ public function handle(Request $request): Response|ResponseFactory
return Response::error('No enabled platform on this post accepts this media type.');
}
$post->appendMedia([[
$item = [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => $media->type,
'mime_type' => $media->mime_type,
'original_filename' => $media->original_filename,
]]);
];
if (($alt = data_get($validated, 'alt')) !== null && $media->isImage()) {
$item['meta'] = ['alt_text' => $alt];
}
$post->appendMedia([$item]);
$post->refresh()->load(['postPlatforms.socialAccount', 'labels']);
@ -69,6 +77,7 @@ public function schema(JsonSchema $schema): array
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'),
'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'),
'alt' => $schema->string()->description('Optional accessibility alt text for the media (applies to images).'),
];
}
}

View file

@ -7,6 +7,7 @@
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use App\Services\Post\MediaAttacher;
use App\Support\PostMediaRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -22,7 +23,8 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'urls' => ['required', 'array', 'min:1', 'max:10'],
'urls.*' => ['url:http,https', 'active_url'],
'urls.*.url' => ['required', 'url:http,https', 'active_url'],
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
]);
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
@ -51,9 +53,12 @@ public function schema(JsonSchema $schema): array
return [
'post_id' => $schema->string()->required()->description('UUID of the post to attach media to.'),
'urls' => $schema->array()
->items($schema->string())
->items($schema->object(fn ($u) => [
'url' => $u->string()->required()->description('Public HTTP/HTTPS URL of an image, video, or PDF.'),
'alt' => $u->string()->description('Optional accessibility alt text for the image (ignored for video/PDF, which have no alt text).'),
]))
->required()
->description('Public HTTP/HTTPS URLs of images, videos, or PDFs. Max 10 URLs per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
->description('Media to attach. Max 10 per call, 50MB per file. Allowed types: image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, application/pdf.'),
];
}
}

View file

@ -25,7 +25,7 @@
class MediaAttacher
{
/**
* @param array<int, string> $urls
* @param array<int, array{url: string, alt?: ?string}> $urls
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
*/
public function attachFromUrls(Post $post, array $urls): array
@ -33,10 +33,22 @@ public function attachFromUrls(Post $post, array $urls): array
$attached = [];
$failed = [];
foreach ($urls as $url) {
($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null
? $failed[] = $url
: $attached[] = $item;
foreach ($urls as $entry) {
$url = (string) data_get($entry, 'url', '');
$item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url);
if ($item === null) {
$failed[] = $url;
continue;
}
if (($alt = data_get($entry, 'alt')) !== null
&& MediaType::classify(data_get($item, 'mime_type'), data_get($item, 'path')) === MediaType::Image) {
$item['meta'] = ['alt_text' => $alt];
}
$attached[] = $item;
}
if ($attached !== []) {
@ -77,6 +89,10 @@ public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, ar
continue;
}
if (($meta = data_get($item, 'meta')) !== null) {
$hosted['meta'] = $meta;
}
$media[] = $hosted;
$hostedIds[] = data_get($hosted, 'id');
}

View file

@ -121,10 +121,14 @@ private function publishPost(?string $content, $media): array
$payload = $this->basePayload($content);
if ($media->isNotEmpty()) {
$mediaUrn = $this->uploadMedia($media->first());
$item = $media->first();
$mediaUrn = $this->uploadMedia($item);
if ($mediaUrn) {
$payload['content'] = ['media' => ['id' => $mediaUrn]];
$payload['content'] = ['media' => array_filter([
'id' => $mediaUrn,
'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null,
], fn ($v) => $v !== null)];
}
}
@ -143,10 +147,10 @@ private function publishCarousel(?string $content, $media): array
$imageUrn = $this->uploadImage($item);
if ($imageUrn) {
$images[] = [
$images[] = array_filter([
'id' => $imageUrn,
'altText' => $item->original_filename ?? 'Carousel image',
];
'altText' => $item->altTextFor($this->platform()),
], fn ($v) => $v !== null);
}
}

View file

@ -63,7 +63,7 @@ public function publish(PostPlatform $postPlatform): array
$blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type);
if ($blob) {
$images[] = [
'alt' => '',
'alt' => $media->altTextFor(Platform::Bluesky) ?? '',
'image' => $blob,
];
}

View file

@ -126,7 +126,16 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
$attachments[] = ['id' => $index, 'filename' => $filename];
$attachment = ['id' => $index, 'filename' => $filename];
$alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null;
if ($alt !== null) {
$attachment['description'] = $alt;
}
$attachments[] = $attachment;
}
$payload['attachments'] = $attachments;

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
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 +135,12 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['message'] = $content;
}
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$payload['alt_text_custom'] = $alt;
}
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
if ($response->failed()) {
@ -163,11 +170,19 @@ 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,
]);
];
$alt = $media->altTextFor(Platform::Facebook);
if ($alt !== null) {
$uploadPayload['alt_text_custom'] = $alt;
}
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload);
if ($uploadResponse->failed()) {
Log::error('Facebook image upload failed', [

View file

@ -5,6 +5,7 @@
namespace App\Services\Social;
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 +82,20 @@ 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,
]);
];
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$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 +215,12 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$params['media_type'] = 'VIDEO';
} else {
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio);
$alt = $media->altTextFor(Platform::Instagram);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);

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);
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
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', $altText);
}
$response = $request->post("{$instance}/api/v1/media");
if (is_resource($stream)) {
fclose($stream);

View file

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

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\ThreadsPublishException;
use App\Models\PostPlatform;
@ -100,12 +101,20 @@ private function publishTextPost(string $userId, string $accessToken, string $co
private function publishImagePost(string $userId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", [
$params = [
'media_type' => 'IMAGE',
'image_url' => $media->url,
'text' => $content,
'access_token' => $accessToken,
]);
];
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);
if ($containerResponse->failed()) {
Log::error('Threads image container creation failed', [
@ -184,6 +193,12 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c
} else {
$params['media_type'] = 'IMAGE';
$params['image_url'] = $media->url;
$alt = $media->altTextFor(Platform::Threads);
if ($alt !== null) {
$params['alt_text'] = $alt;
}
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads", $params);

View file

@ -4,6 +4,7 @@
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;
@ -15,6 +16,7 @@
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class XPublisher
{
@ -60,6 +62,7 @@ public function publish(PostPlatform $postPlatform): array
$mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id'));
if ($mediaId) {
$mediaIds[] = $mediaId;
$this->uploadAltText($mediaId, $mediaItem);
}
}
}
@ -106,6 +109,41 @@ private function getHttpClient(): PendingRequest
]);
}
/**
* Sets the image's accessibility description on X via the v2 media
* metadata endpoint. Best-effort: only images carry alt text, and a failure
* here never blocks the tweet — the media already uploaded and the post
* should still go out without the description.
*/
private function uploadAltText(string $mediaId, MediaItem $mediaItem): void
{
if (! $mediaItem->isImage()) {
return;
}
$alt = $mediaItem->altTextFor(Platform::X);
if ($alt === null) {
return;
}
try {
$this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [
'id' => $mediaId,
'metadata' => [
'alt_text' => [
'text' => $alt,
],
],
]);
} catch (Throwable $e) {
Log::warning('X alt text upload failed; posting the tweet without it', [
'media_id' => $mediaId,
'error' => $e->getMessage(),
]);
}
}
private function uploadMedia($mediaItem): ?array
{
$mimeType = $mediaItem->mime_type;

View file

@ -5,6 +5,7 @@
namespace App\Support;
use App\Enums\Media\Source;
use Closure;
use Illuminate\Validation\Rule;
/**
@ -15,6 +16,12 @@
*/
class PostMediaRules
{
/**
* Maximum stored length (characters) for a media item's alt text. Publishers
* truncate further to each platform's own cap via Platform::altTextMaxLength().
*/
public const ALT_TEXT_MAX_LENGTH = 2000;
/**
* @param bool $hosted true (web): items must already be hosted (id + path
* required); false (API): a bare external `url` is
@ -34,7 +41,23 @@ public static function rules(bool $hosted): array
'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'],
'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'],
'media.*.size' => ['sometimes', 'nullable', 'integer'],
'media.*.meta' => ['sometimes', 'nullable', 'array'],
'media.*.meta' => ['sometimes', 'nullable', 'array', static function (string $attribute, mixed $value, Closure $fail): void {
$altText = data_get($value, 'alt_text');
if ($altText === null) {
return;
}
if (! is_string($altText)) {
$fail('validation.string')->translate(['attribute' => trans('posts.edit.alt_text.label')]);
return;
}
if (mb_strlen($altText) > self::ALT_TEXT_MAX_LENGTH) {
$fail('validation.max.string')->translate(['attribute' => trans('posts.edit.alt_text.label'), 'max' => self::ALT_TEXT_MAX_LENGTH]);
}
}],
'media.*.source' => ['sometimes', 'nullable', 'string', Rule::in(array_column(Source::cases(), 'value'))],
'media.*.source_meta' => ['sometimes', 'nullable', 'array'],
];

View file

@ -378,6 +378,14 @@
'add_count' => 'إضافة :count',
],
'alt_text' => [
'label' => 'نص بديل',
'edit' => 'تحرير النص البديل',
'placeholder' => 'صف هذه الصورة للأشخاص الذين يستخدمون برامج قراءة الشاشة',
'hint' => 'يُنشر على المنصات التي تدعم أوصاف الصور.',
'save' => 'حفظ',
],
'emoji_picker' => [
'search' => 'البحث عن رمز تعبيري',
'empty' => 'لم يتم العثور على رموز تعبيرية',

View file

@ -380,6 +380,14 @@
'add_count' => ':count hinzufügen',
],
'alt_text' => [
'label' => 'Alternativtext',
'edit' => 'Alternativtext bearbeiten',
'placeholder' => 'Beschreibe dieses Bild für Menschen, die Screenreader verwenden',
'hint' => 'Wird auf Plattformen veröffentlicht, die Bildbeschreibungen unterstützen.',
'save' => 'Speichern',
],
'emoji_picker' => [
'search' => 'Emoji suchen',
'empty' => 'Keine Emojis gefunden',

View file

@ -378,6 +378,14 @@
'add_count' => 'Προσθήκη :count',
],
'alt_text' => [
'label' => 'Εναλλακτικό κείμενο',
'edit' => 'Επεξεργασία εναλλακτικού κειμένου',
'placeholder' => 'Περιγράψτε αυτήν την εικόνα για άτομα που χρησιμοποιούν προγράμματα ανάγνωσης οθόνης',
'hint' => 'Δημοσιεύεται σε πλατφόρμες που υποστηρίζουν περιγραφές εικόνων.',
'save' => 'Αποθήκευση',
],
'emoji_picker' => [
'search' => 'Αναζήτηση emoji',
'empty' => 'Δεν βρέθηκαν emoji',

View file

@ -378,6 +378,14 @@
'add_count' => 'Add :count',
],
'alt_text' => [
'label' => 'Alt text',
'edit' => 'Edit alt text',
'placeholder' => 'Describe this image for people using screen readers',
'hint' => 'Published to platforms that support image descriptions.',
'save' => 'Save',
],
'emoji_picker' => [
'search' => 'Search emoji',
'empty' => 'No emojis found',

View file

@ -378,6 +378,14 @@
'add_count' => 'Agregar :count',
],
'alt_text' => [
'label' => 'Texto alternativo',
'edit' => 'Editar texto alternativo',
'placeholder' => 'Describe esta imagen para las personas que usan lectores de pantalla',
'hint' => 'Se publica en las plataformas que admiten descripciones de imágenes.',
'save' => 'Guardar',
],
'emoji_picker' => [
'search' => 'Buscar emoji',
'empty' => 'No se encontraron emojis',

View file

@ -378,6 +378,14 @@
'add_count' => 'Ajouter :count',
],
'alt_text' => [
'label' => 'Texte alternatif',
'edit' => 'Modifier le texte alternatif',
'placeholder' => 'Décrivez cette image pour les personnes utilisant un lecteur d\'écran',
'hint' => 'Publié sur les plateformes qui prennent en charge les descriptions d\'images.',
'save' => 'Enregistrer',
],
'emoji_picker' => [
'search' => 'Rechercher un émoji',
'empty' => 'Aucun émoji trouvé',

View file

@ -378,6 +378,14 @@
'add_count' => 'Aggiungi :count',
],
'alt_text' => [
'label' => 'Testo alternativo',
'edit' => 'Modifica testo alternativo',
'placeholder' => 'Descrivi questa immagine per le persone che usano screen reader',
'hint' => 'Pubblicato sulle piattaforme che supportano le descrizioni delle immagini.',
'save' => 'Salva',
],
'emoji_picker' => [
'search' => 'Cerca emoji',
'empty' => 'Nessuna emoji trovata',

View file

@ -378,6 +378,14 @@
'add_count' => ':count 件を追加',
],
'alt_text' => [
'label' => '代替テキスト',
'edit' => '代替テキストを編集',
'placeholder' => 'スクリーンリーダーを使用する人のためにこの画像を説明してください',
'hint' => '画像の説明に対応しているプラットフォームに公開されます。',
'save' => '保存',
],
'emoji_picker' => [
'search' => '絵文字を検索',
'empty' => '絵文字が見つかりません',

View file

@ -378,6 +378,14 @@
'add_count' => ':count개 추가',
],
'alt_text' => [
'label' => '대체 텍스트',
'edit' => '대체 텍스트 편집',
'placeholder' => '화면 낭독 프로그램을 사용하는 사람들을 위해 이 이미지를 설명하세요',
'hint' => '이미지 설명을 지원하는 플랫폼에 게시됩니다.',
'save' => '저장',
],
'emoji_picker' => [
'search' => '이모지 검색',
'empty' => '이모지를 찾을 수 없습니다',

View file

@ -378,6 +378,14 @@
'add_count' => ':count toevoegen',
],
'alt_text' => [
'label' => 'Alt-tekst',
'edit' => 'Alt-tekst bewerken',
'placeholder' => 'Beschrijf deze afbeelding voor mensen die schermlezers gebruiken',
'hint' => 'Wordt gepubliceerd op platforms die afbeeldingsbeschrijvingen ondersteunen.',
'save' => 'Opslaan',
],
'emoji_picker' => [
'search' => 'Emoji zoeken',
'empty' => 'Geen emoji\'s gevonden',

View file

@ -378,6 +378,14 @@
'add_count' => 'Dodaj :count',
],
'alt_text' => [
'label' => 'Tekst alternatywny',
'edit' => 'Edytuj tekst alternatywny',
'placeholder' => 'Opisz ten obraz dla osób korzystających z czytników ekranu',
'hint' => 'Publikowany na platformach obsługujących opisy obrazów.',
'save' => 'Zapisz',
],
'emoji_picker' => [
'search' => 'Szukaj emoji',
'empty' => 'Nie znaleziono emoji',

View file

@ -378,6 +378,14 @@
'add_count' => 'Adicionar :count',
],
'alt_text' => [
'label' => 'Texto alternativo',
'edit' => 'Editar texto alternativo',
'placeholder' => 'Descreva esta imagem para pessoas que usam leitores de tela',
'hint' => 'Publicado nas plataformas que oferecem suporte a descrições de imagem.',
'save' => 'Salvar',
],
'emoji_picker' => [
'search' => 'Buscar emoji',
'empty' => 'Nenhum emoji encontrado',

View file

@ -378,6 +378,14 @@
'add_count' => 'Добавить :count',
],
'alt_text' => [
'label' => 'Альтернативный текст',
'edit' => 'Изменить альтернативный текст',
'placeholder' => 'Опишите это изображение для людей, использующих программы чтения с экрана',
'hint' => 'Публикуется на платформах, поддерживающих описания изображений.',
'save' => 'Сохранить',
],
'emoji_picker' => [
'search' => 'Поиск эмодзи',
'empty' => 'Эмодзи не найдены',

View file

@ -380,6 +380,14 @@
'add_count' => ':count tane ekle',
],
'alt_text' => [
'label' => 'Alternatif metin',
'edit' => 'Alternatif metni düzenle',
'placeholder' => 'Ekran okuyucu kullanan kişiler için bu görseli açıklayın',
'hint' => 'Görsel açıklamalarını destekleyen platformlarda yayınlanır.',
'save' => 'Kaydet',
],
'emoji_picker' => [
'search' => 'Emoji ara',
'empty' => 'Emoji bulunamadı',

View file

@ -378,6 +378,14 @@
'add_count' => '添加 :count 个',
],
'alt_text' => [
'label' => '替代文本',
'edit' => '编辑替代文本',
'placeholder' => '为使用屏幕阅读器的用户描述此图片',
'hint' => '发布到支持图片描述的平台。',
'save' => '保存',
],
'emoji_picker' => [
'search' => '搜索表情',
'empty' => '未找到表情',

View file

@ -8,6 +8,7 @@ import { MediaType } from '@/lib/mediaType';
interface PreviewItem {
url: string;
type: MediaType;
altText?: string;
}
const items = ref<PreviewItem[]>([]);
@ -99,6 +100,7 @@ defineExpose({ open, openCollection, close });
v-else-if="currentItem && currentItem.type === 'video'"
:key="currentItem.url"
:src="currentItem.url"
data-testid="lightbox-video"
class="max-h-[85vh] max-w-full rounded-2xl bg-black"
controls
autoplay
@ -136,6 +138,15 @@ defineExpose({ open, openCollection, close });
<IconChevronRight class="size-6" />
</button>
<div
v-if="currentItem?.altText"
data-testid="lightbox-alt-text"
class="pointer-events-none absolute left-1/2 max-w-2xl -translate-x-1/2 rounded-lg bg-black/70 px-4 py-2 text-center text-sm text-white backdrop-blur-sm"
:class="showNav ? 'bottom-14' : 'bottom-4'"
>
{{ currentItem.altText }}
</div>
<div
v-if="showNav"
class="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs text-white tabular-nums"

View file

@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { MediaItem } from '@/types/media';
const MAX_ALT_TEXT_LENGTH = 2000;
const props = defineProps<{
mediaItem: MediaItem | null;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
(e: 'save', value: string): void;
}>();
const value = ref('');
const length = computed(() => [...value.value.trim()].length);
const isOverLimit = computed(() => length.value > MAX_ALT_TEXT_LENGTH);
watch(open, (isOpen) => {
if (isOpen) {
value.value = props.mediaItem?.meta?.alt_text ?? '';
}
});
const save = () => {
if (isOverLimit.value) {
return;
}
emit('save', value.value);
open.value = false;
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-xl" data-testid="alt-text-dialog">
<DialogHeader>
<DialogTitle>{{ $t('posts.edit.alt_text.edit') }}</DialogTitle>
<DialogDescription>{{ $t('posts.edit.alt_text.hint') }}</DialogDescription>
</DialogHeader>
<div class="space-y-2">
<Label for="alt-text-input">{{ $t('posts.edit.alt_text.label') }}</Label>
<Textarea
id="alt-text-input"
v-model="value"
:placeholder="$t('posts.edit.alt_text.placeholder')"
rows="4"
data-testid="alt-text-input"
/>
<p
class="text-right text-xs tabular-nums"
:class="isOverLimit ? 'text-destructive' : 'text-foreground/60'"
data-testid="alt-text-counter"
>
{{ length }} / {{ MAX_ALT_TEXT_LENGTH }}
</p>
</div>
<DialogFooter>
<Button data-testid="alt-text-save" :disabled="isOverLimit" @click="save">
{{ $t('posts.edit.alt_text.save') }}
</Button>
<Button variant="outline" @click="open = false">
{{ $t('posts.edit.cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import {
IconAlertTriangle,
IconAlt,
IconFileTypePdf,
IconGripVertical,
IconHash,
@ -16,15 +17,15 @@ import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, ref } from 'vue';
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
import AltTextDialog from '@/components/posts/editor/AltTextDialog.vue';
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
import SignaturesModal from '@/components/posts/SignaturesModal.vue';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatBytes } from '@/composables/useMedia';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import { classify, isDocument, isVideo, MediaType } from '@/lib/mediaType';
import { classify, isDocument, isImage, isVideo, MediaType } from '@/lib/mediaType';
import type { MediaItem } from '@/types/media';
interface Signature {
@ -82,6 +83,7 @@ const openPreview = (item: MediaItem) => {
media.value.map((m) => ({
url: m.url,
type: classify(m) ?? MediaType.Image,
altText: isImage(m) ? m.meta?.alt_text : undefined,
})),
idx,
);
@ -205,6 +207,29 @@ const onMediaKeydown = async (event: KeyboardEvent, index: number) => {
const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reason}`);
const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerate && item.source === 'ai';
const altDialogOpen = ref(false);
const altDialogIndex = ref<number | null>(null);
const altDialogItem = computed<MediaItem | null>(() =>
altDialogIndex.value !== null ? (media.value[altDialogIndex.value] ?? null) : null,
);
const openAltText = (index: number) => {
altDialogIndex.value = index;
altDialogOpen.value = true;
};
const onAltTextSave = (alt: string): void => {
if (altDialogIndex.value === null) return;
const next = [...media.value];
const trimmed = alt.trim();
next[altDialogIndex.value] = {
...next[altDialogIndex.value],
meta: { ...(next[altDialogIndex.value].meta ?? {}), alt_text: trimmed || undefined },
};
media.value = next;
};
</script>
<template>
@ -217,6 +242,7 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
v-for="(item, index) in media"
:key="item.id"
:ref="(el) => { if (el) mediaThumbRefs[index] = el as HTMLElement; }"
data-testid="media-thumbnail"
class="group relative aspect-square cursor-zoom-in overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all focus:outline-none focus:ring-2 focus:ring-foreground focus:ring-offset-2"
:class="[
dragMediaIndex === index ? 'opacity-40' : '',
@ -255,7 +281,7 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
<div class="absolute bottom-1.5 left-1.5 flex flex-col items-start gap-1 text-[10px] font-medium text-white">
<div class="pointer-events-none absolute inset-x-1.5 bottom-1.5 flex flex-col items-start gap-1 text-[10px] font-medium text-white">
<span
v-if="isVideo(item) && item.meta?.duration"
class="inline-flex items-center gap-0.5 rounded-md bg-black/65 px-1.5 py-0.5 backdrop-blur-sm"
@ -264,10 +290,11 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
{{ date.formatClock(item.meta.duration) }}
</span>
<span
v-if="item.size"
class="inline-flex rounded-md bg-black/65 px-1.5 py-0.5 backdrop-blur-sm"
v-if="isImage(item) && item.meta?.alt_text"
class="inline-block max-w-full truncate rounded-md bg-black/65 px-1.5 py-0.5 backdrop-blur-sm"
data-testid="alt-text-badge"
>
{{ formatBytes(item.size) }}
{{ item.meta.alt_text }}
</span>
</div>
@ -308,6 +335,18 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
{{ $t('posts.ai.image_regenerate.button') }}
</button>
<button
v-if="!readOnly && isImage(item)"
type="button"
:title="$t('posts.edit.alt_text.edit')"
:aria-label="$t('posts.edit.alt_text.edit')"
class="absolute right-9 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
data-testid="alt-text-button"
@click.stop="openAltText(index)"
>
<IconAlt class="size-3.5" />
</button>
<button
v-if="!readOnly"
type="button"
@ -449,5 +488,6 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
<ImagePreviewDialog ref="lightbox" />
<AltTextDialog v-model:open="altDialogOpen" :media-item="altDialogItem" @save="onAltTextSave" />
</div>
</template>

View file

@ -268,11 +268,13 @@ const save = () => {
...data,
}, {
preserveScroll: true,
onFinish: () => {
isSaving.value = false;
onSuccess: () => {
showSaved.value = true;
setTimeout(() => { showSaved.value = false; }, 2000);
},
onFinish: () => {
isSaving.value = false;
},
});
};

View file

@ -16,5 +16,6 @@ export interface MediaItem {
width?: number;
height?: number;
duration?: number;
alt_text?: string;
};
}

View file

@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
/**
* Flag when the composer's debounced autosave PUT finishes so the test can wait
* on the real round-trip instead of a fixed sleep. Inertia v3 issues the update
* over the built-in XHR client, so the completion is observable via loadend.
*/
function trackAutosave(mixed $page): void
{
$page->script(<<<'JS'
(() => {
window.__autosaveDone = false;
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method) {
this.__method = (method || '').toUpperCase();
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
this.addEventListener('loadend', () => {
if (this.__method === 'PUT') {
window.__autosaveDone = true;
}
});
return send.apply(this, arguments);
};
})();
JS);
}
/**
* Poll on the browser side until the autosave PUT has completed, keeping the
* in-process server pumped while the debounce (1.5s) elapses and the request
* round-trips.
*/
function waitForAutosave(mixed $page): void
{
$page->script(<<<'JS'
(async () => {
for (let attempt = 0; attempt < 100 && ! window.__autosaveDone; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
})();
JS);
}
/**
* Poll on the browser side until the lightbox alt-text caption has mounted and
* laid out. The lightbox is a Radix dialog with an open animation, so the
* element is not present the instant the thumbnail is clicked; the harness
* assertions do not auto-wait, so we wait here first.
*/
function waitForLightboxAltText(mixed $page): void
{
$page->script(<<<'JS'
(async () => {
const visible = () => {
const el = document.querySelector('[data-testid="lightbox-alt-text"]');
if (! el) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
for (let attempt = 0; attempt < 100 && ! visible(); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
})();
JS);
}
/**
* Poll on the browser side until the alt-text Save button reaches the desired
* disabled state. The `:disabled` binding is driven by a Vue computed that
* flushes on nextTick, and these Pest browser assertions do not auto-wait, so
* we settle the reactive state here before asserting.
*/
function waitForSaveButton(mixed $page, bool $disabled): void
{
$want = $disabled ? 'true' : 'false';
$page->script(<<<JS
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
const el = document.querySelector('[data-testid="alt-text-save"]');
if (el && el.disabled === {$want}) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
})();
JS);
}
test('editing alt text on an attached image persists it to the post media', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
trackAutosave($page);
$page->click('@alt-text-button')
->type('@alt-text-input', 'a golden retriever on a beach')
->click('@alt-text-save');
waitForAutosave($page);
expect($post->fresh()->media[0]['meta']['alt_text'])
->toBe('a golden retriever on a beach');
$page->click('@media-thumbnail');
waitForLightboxAltText($page);
$page->assertSeeIn('@lightbox-alt-text', 'a golden retriever on a beach');
});
test('lightbox shows no alt overlay for a non-image even when meta carries alt text', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'v1',
'type' => 'video',
'mime_type' => 'video/mp4',
'path' => 'uploads/clip.mp4',
'url' => 'https://cdn.test/clip.mp4',
'meta' => ['alt_text' => 'alt that must never overlay a video'],
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@media-thumbnail');
$page->script(<<<'JS'
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('[data-testid="lightbox-video"]')) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
})();
JS);
$page->assertPresent('@lightbox-video')
->assertMissing('@lightbox-alt-text');
});
test('alt text dialog blocks saving when the description exceeds the limit', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@alt-text-button')
->fill('@alt-text-input', str_repeat('a', 2001));
waitForSaveButton($page, disabled: true);
$page->assertDisabled('@alt-text-save')
->fill('@alt-text-input', 'a short and valid description');
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
$page->fill('@alt-text-input', str_repeat('a', 1999).str_repeat(' ', 100));
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
});
test('alt text dialog counts emoji by code point, not UTF-16 units', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@alt-text-button')
->fill('@alt-text-input', str_repeat('😀', 1500));
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
});

View file

@ -3,9 +3,11 @@
declare(strict_types=1);
use App\Jobs\Ai\RegeneratePostMediaImage;
use App\Models\Media;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
test('job fallback source context uses post content when source_meta is missing', function () {
$user = User::factory()->create();
@ -82,6 +84,62 @@
->and(data_get($copy, 'change_mode'))->toBe('image_only');
});
test('regenerating an image keeps the current saved alt text, not a pre-render snapshot', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create([
'user_id' => $user->id,
'account_id' => $user->account_id,
]);
Storage::fake();
Storage::put('ai-images/regen.webp', 'fake-image-bytes');
$targetMedia = Media::factory()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $workspace->id,
'collection' => 'ai-generated',
]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'media' => [[
'id' => $targetMedia->id,
'type' => 'image',
'source' => 'ai',
'path' => 'ai-images/old.webp',
'url' => 'https://cdn.test/old.webp',
'meta' => ['alt_text' => 'CURRENT alt saved during regen', 'slide_title' => 'Intro'],
]],
]);
$job = new RegeneratePostMediaImage(
workspaceId: $workspace->id,
postId: $post->id,
userId: $user->id,
mediaId: $targetMedia->id,
regenerationId: 'regen-id',
instruction: 'image only tweak',
);
// Snapshot captured before the async render carries a now-stale alt.
$staleTarget = [
'id' => $targetMedia->id,
'type' => 'image',
'source' => 'ai',
'meta' => ['alt_text' => 'STALE alt from before regen'],
];
$rendered = ['path' => 'ai-images/regen.webp', 'source_meta' => []];
$method = new ReflectionMethod(RegeneratePostMediaImage::class, 'replaceMediaOnPost');
$method->setAccessible(true);
$method->invoke($job, $post, $staleTarget, $workspace, $rendered);
expect(data_get($post->fresh()->media, '0.meta.alt_text'))->toBe('CURRENT alt saved during regen')
->and(data_get($post->fresh()->media, '0.meta.slide_title'))->toBe('Intro');
});
test('merge structured copy keeps keywords unchanged for text_only mode', function () {
$job = new RegeneratePostMediaImage(
workspaceId: 'workspace-id',

View file

@ -49,7 +49,7 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => ['https://example.com/photo.png'],
'urls' => [['url' => 'https://example.com/photo.png']],
])
->assertOk()
->assertJsonPath('attached_count', 1)
@ -59,6 +59,63 @@
expect($this->post->fresh()->media)->toHaveCount(1);
});
it('attaches media from url with alt text', function () {
Http::fake([
'example.com/photo.png' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => [['url' => 'https://example.com/photo.png', 'alt' => 'A red bicycle by a wall']],
])
->assertOk()
->assertJsonPath('attached_count', 1);
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('A red bicycle by a wall');
});
it('does not store alt text on a non-image url', function () {
Http::fake([
'example.com/deck.pdf' => Http::response(
"%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n",
200,
['Content-Type' => 'application/pdf'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => [['url' => 'https://example.com/deck.pdf', 'alt' => 'alt is meaningless for a pdf']],
])
->assertOk()
->assertJsonPath('attached_count', 1);
expect(data_get($this->post->fresh()->media, '0.type'))->toBe('document')
->and(data_get($this->post->fresh()->media, '0.meta'))->toBeNull();
});
it('rejects alt text over the max length on a url', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => [['url' => 'https://example.com/photo.png', 'alt' => str_repeat('a', 2001)]],
])
->assertUnprocessable()
->assertJsonValidationErrors(['urls.0.alt']);
});
it('rejects the old bare-string urls shape', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => ['https://example.com/photo.png'],
])
->assertUnprocessable()
->assertJsonValidationErrors(['urls.0.url']);
});
it('reports failures for unreachable urls', function () {
Http::fake([
'example.com/missing.png' => Http::response(null, 404),
@ -66,7 +123,7 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $this->post), [
'urls' => ['https://example.com/missing.png'],
'urls' => [['url' => 'https://example.com/missing.png']],
])
->assertOk()
->assertJsonPath('attached_count', 0)
@ -79,7 +136,7 @@
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.attach-media-from-url', $post), [
'urls' => ['https://example.com/photo.png'],
'urls' => [['url' => 'https://example.com/photo.png']],
])
->assertNotFound();
});
@ -250,6 +307,36 @@
expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1);
});
it('persists alt text submitted on a bare external media url', function () {
$this->socialAccount->update(['is_active' => true]);
Http::fake([
'cdn.example.com/car.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => 'External alt post',
'media' => [[
'url' => 'https://cdn.example.com/car.jpg',
'meta' => ['alt_text' => 'A red car parked on a hill'],
]],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
])
->assertCreated();
$media = Post::where('content', 'External alt post')->firstOrFail()->media;
expect(data_get($media, '0.meta.alt_text'))->toBe('A red car parked on a hill')
->and(data_get($media, '0.path'))->not->toBeNull();
});
it('rejects creating a post when an external media url cannot be fetched', function () {
$this->socialAccount->update(['is_active' => true]);
@ -446,3 +533,96 @@
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('preserves every media meta key on update, not just alt_text', 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' => [
'width' => 1920,
'height' => 1080,
'duration' => 30,
'alt_text' => 'A description of the photo',
],
]],
])
->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta'))->toMatchArray([
'width' => 1920,
'height' => 1080,
'duration' => 30,
'alt_text' => 'A description of the photo',
]);
});
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']);
expect(Post::where('content', 'Alt text too long post')->exists())->toBeFalse();
});

View file

@ -42,6 +42,50 @@
expect($this->post->fresh()->media)->toHaveCount(1);
});
test('attaches an uploaded Media with alt text stored in meta', function () {
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUploadTool::class, [
'post_id' => $this->post->id,
'upload_token' => $this->token,
'alt' => 'A scenic mountain view',
]);
$response->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('A scenic mountain view');
});
test('does not store alt text on a non-image upload', function () {
$video = Media::factory()->video()->create([
'mediable_type' => (new Workspace)->getMorphClass(),
'mediable_id' => $this->workspace->id,
'collection' => 'assets',
'upload_token' => (string) Str::uuid(),
]);
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUploadTool::class, [
'post_id' => $this->post->id,
'upload_token' => $video->upload_token,
'alt' => 'alt is meaningless for a video',
]);
$response->assertOk();
expect(data_get($this->post->fresh()->media, '0.type'))->toBe('video')
->and(data_get($this->post->fresh()->media, '0.meta'))->toBeNull();
});
test('rejects alt text over the max length', function () {
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUploadTool::class, [
'post_id' => $this->post->id,
'upload_token' => $this->token,
'alt' => str_repeat('a', 2001),
]);
$response->assertHasErrors();
});
test('rejects a token from a different workspace', function () {
$other = User::factory()->create();
$otherWs = Workspace::factory()->create(['user_id' => $other->id]);

View file

@ -42,7 +42,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.com/photo.jpg'],
'urls' => [['url' => 'https://example.com/photo.jpg']],
]);
$response->assertOk();
@ -51,6 +51,26 @@
expect($this->post->fresh()->media)->toHaveCount(1);
});
test('attaches an image from url with alt text and stores it in meta', function () {
Http::fake([
'example.com/photo.jpg' => Http::response(
file_get_contents(__DIR__.'/../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
),
]);
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => [['url' => 'https://example.com/photo.jpg', 'alt' => 'A red bicycle by a wall']],
]);
$response->assertOk();
expect(data_get($this->post->fresh()->media, '0.meta.alt_text'))->toBe('A red bicycle by a wall');
});
test('rejects url that returns non-image content type', function () {
Http::fake([
'example.org/payload' => Http::response('not an image', 200, ['Content-Type' => 'text/html']),
@ -59,7 +79,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.org/payload'],
'urls' => [['url' => 'https://example.org/payload']],
]);
$response->assertOk();
@ -82,8 +102,8 @@
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => [
'https://example.com/ok.png',
'https://example.com/missing.png',
['url' => 'https://example.com/ok.png'],
['url' => 'https://example.com/missing.png'],
],
]);
@ -112,7 +132,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.com/deck.pdf'],
'urls' => [['url' => 'https://example.com/deck.pdf']],
]);
$response->assertOk();
@ -140,7 +160,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.com/deck.pdf'],
'urls' => [['url' => 'https://example.com/deck.pdf']],
]);
$response->assertOk();
@ -155,7 +175,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $post->id,
'urls' => ['https://example.com/photo.jpg'],
'urls' => [['url' => 'https://example.com/photo.jpg']],
]);
$response->assertHasErrors(['Post not found.']);
@ -165,7 +185,7 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['ftp://example.com/photo.jpg'],
'urls' => [['url' => 'ftp://example.com/photo.jpg']],
]);
$response->assertHasErrors();
@ -177,14 +197,34 @@
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['not-a-url-at-all'],
'urls' => [['url' => 'not-a-url-at-all']],
]);
$response->assertHasErrors();
});
test('rejects alt text over the max length', function () {
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => [['url' => 'https://example.com/photo.jpg', 'alt' => str_repeat('a', 2001)]],
]);
$response->assertHasErrors();
});
test('rejects the old bare-string urls shape', function () {
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.com/photo.jpg'],
]);
$response->assertHasErrors();
});
test('rejects more than 10 urls per call', function () {
$urls = collect(range(1, 11))->map(fn ($i) => "https://example.com/photo-{$i}.jpg")->all();
$urls = collect(range(1, 11))->map(fn ($i) => ['url' => "https://example.com/photo-{$i}.jpg"])->all();
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [

View file

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
test('post update keeps media alt_text in meta', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg',
'meta' => ['alt_text' => 'a golden retriever on a beach'],
]],
]);
$response->assertSessionDoesntHaveErrors();
expect($post->fresh()->media[0]['meta']['alt_text'])->toBe('a golden retriever on a beach');
});
test('post update preserves every media meta key, not just alt_text', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg', 'type' => 'image',
'meta' => [
'width' => 1080,
'height' => 1350,
'duration' => 12,
'slide_title' => 'Intro slide',
'alt_text' => 'a golden retriever on a beach',
],
]],
]);
$response->assertSessionDoesntHaveErrors();
$meta = $post->fresh()->media[0]['meta'];
expect($meta['width'])->toBe(1080)
->and($meta['height'])->toBe(1350)
->and($meta['duration'])->toBe(12)
->and($meta['slide_title'])->toBe('Intro slide')
->and($meta['alt_text'])->toBe('a golden retriever on a beach');
});
test('media alt_text over 2000 chars is rejected', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg',
'meta' => ['alt_text' => str_repeat('a', 2001)],
]],
]);
$response->assertSessionHasErrors('media.0.meta');
});
test('media alt_text at exactly 2000 chars is accepted', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$altText = str_repeat('a', 2000);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg',
'meta' => ['alt_text' => $altText],
]],
]);
$response->assertSessionDoesntHaveErrors();
expect($post->fresh()->media[0]['meta']['alt_text'])->toBe($altText);
});
test('non-string media alt_text is rejected', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg',
'meta' => ['alt_text' => ['not', 'a', 'string']],
]],
]);
$response->assertSessionHasErrors('media.0.meta');
});

View file

@ -403,6 +403,97 @@
});
});
test('bluesky publisher includes image alt text in the embed', 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',
'meta' => ['alt_text' => 'a red bike'],
],
],
]);
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake(function ($request) {
if (str_contains($request->url(), 'uploadBlob')) {
return Http::response([
'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafkreiabc123'], 'mimeType' => 'image/jpeg', 'size' => 1024],
], 200);
}
if (str_contains($request->url(), 'createRecord')) {
return Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200);
}
return Http::response(str_repeat('x', 1024), 200); // media download
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
return data_get($request->data(), 'record.embed.images.0.alt') === 'a red bike';
});
});
test('bluesky publisher sends empty-string alt when none 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',
],
],
]);
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake(function ($request) {
if (str_contains($request->url(), 'uploadBlob')) {
return Http::response([
'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafkreiabc123'], 'mimeType' => 'image/jpeg', 'size' => 1024],
], 200);
}
if (str_contains($request->url(), 'createRecord')) {
return Http::response([
'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz',
'cid' => 'bafyreiabc123',
], 200);
}
return Http::response(str_repeat('x', 1024), 200); // media download
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'createRecord')) {
return false;
}
return data_get($request->data(), 'record.embed.images.0.alt') === '';
});
});
test('bluesky publisher refreshes token when expired', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);

View file

@ -168,6 +168,117 @@ function fakeDiscord(array $messageResponse = ['id' => '777'], int $status = 200
});
});
test('sets the attachment description from image alt text, capped at the platform max', function () {
$longAlt = str_repeat('a', 2000);
$this->post->update([
'media' => [[
'id' => 'm1',
'path' => 'media/2026-01/pic.jpg',
'url' => 'https://example.com/media/2026-01/pic.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'pic.jpg',
'meta' => ['alt_text' => $longAlt],
]],
]);
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake([
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '902'], 200),
]);
$this->publisher->publish(($this->makePostPlatform)());
$expectedAlt = mb_substr($longAlt, 0, Platform::Discord->altTextMaxLength());
Http::assertSent(function ($request) use ($expectedAlt) {
if (! str_contains($request->url(), '/messages')) {
return false;
}
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
$payload = json_decode(data_get($payloadPart, 'contents'), true);
return data_get($payload, 'attachments.0.description') === $expectedAlt
&& mb_strlen($expectedAlt) === Platform::Discord->altTextMaxLength();
});
});
test('omits the attachment description when the image has no alt text', function () {
$this->post->update([
'media' => [[
'id' => 'm1',
'path' => 'media/2026-01/pic.jpg',
'url' => 'https://example.com/media/2026-01/pic.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'pic.jpg',
]],
]);
$this->mock(MediaOptimizer::class)
->shouldReceive('optimizeImage')
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
Http::fake([
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '903'], 200),
]);
$this->publisher->publish(($this->makePostPlatform)());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/messages')) {
return false;
}
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
$payload = json_decode(data_get($payloadPart, 'contents'), true);
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
});
});
test('does not set a description on a non-image attachment even if it carries alt text', function () {
$this->post->update([
'media' => [[
'id' => 'm1',
'path' => 'media/2026-01/clip.mp4',
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
]],
]);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '904'], 200),
]);
$this->publisher->publish(($this->makePostPlatform)());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/messages')) {
return false;
}
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
$payload = json_decode(data_get($payloadPart, 'contents'), true);
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
});
});
test('throws when no channel is selected', function () {
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(meta: [])))
->toThrow(DiscordPublishException::class);

View file

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

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

View file

@ -224,6 +224,165 @@
);
});
test('linkedin publisher sends the real alt text on a single image post', 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',
'meta' => ['alt_text' => 'A golden retriever catching a frisbee in the park'],
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/images')) {
return Http::response([
'value' => [
'uploadUrl' => $uploadUrl,
'image' => 'urn:li:image:C5622AQFakeImageUrn',
],
], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:altsingle']);
}
// Media download fallback
return Http::response('fake-image-content', 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
return data_get($request->data(), 'content.media.altText') === 'A golden retriever catching a frisbee in the park'
&& data_get($request->data(), 'content.media.altText') !== 'test.jpg';
});
});
test('linkedin publisher omits alt text on a single image post when none 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',
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/images')) {
return Http::response([
'value' => [
'uploadUrl' => $uploadUrl,
'image' => 'urn:li:image:C5622AQFakeImageUrn',
],
], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:altnone']);
}
// Media download fallback
return Http::response('fake-image-content', 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
$media = data_get($request->data(), 'content.media');
return is_array($media)
&& $media['id'] === 'urn:li:image:C5622AQFakeImageUrn'
&& ! array_key_exists('altText', $media);
});
});
test('linkedin publisher truncates single image alt text to the platform max length', function () {
$longAlt = str_repeat('a', 4200);
$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],
],
],
]);
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/images')) {
return Http::response([
'value' => [
'uploadUrl' => $uploadUrl,
'image' => 'urn:li:image:C5622AQFakeImageUrn',
],
], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:alttrunc']);
}
// Media download fallback
return Http::response('fake-image-content', 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
return data_get($request->data(), 'content.media.altText') === str_repeat('a', 4086);
});
});
test('linkedin publisher can publish carousel with multiple images', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
@ -315,6 +474,87 @@
});
});
test('linkedin publisher sends per-image alt text on a carousel, not the filename', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPost]);
$this->post->update([
'media' => [
[
'id' => 'test-media-1',
'path' => 'media/2026-01/carousel-1.jpg',
'url' => 'https://example.com/media/2026-01/carousel-1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'carousel-1.jpg',
'meta' => ['alt_text' => 'A sunset over the mountains'],
],
[
'id' => 'test-media-2',
'path' => 'media/2026-01/carousel-2.jpg',
'url' => 'https://example.com/media/2026-01/carousel-2.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'carousel-2.jpg',
],
],
]);
$uploadUrls = [
'https://www.linkedin.com/dms/upload/v2/pic/carousel/alt/1',
'https://www.linkedin.com/dms/upload/v2/pic/carousel/alt/2',
];
$imageUrns = [
'urn:li:image:CarouselAltUrn1',
'urn:li:image:CarouselAltUrn2',
];
$initCallCount = 0;
Http::fake(function ($request) use ($uploadUrls, $imageUrns, &$initCallCount) {
$url = $request->url();
if (str_contains($url, '/rest/images')) {
$idx = $initCallCount % 2;
$initCallCount++;
return Http::response([
'value' => [
'uploadUrl' => $uploadUrls[$idx],
'image' => $imageUrns[$idx],
],
], 200);
}
foreach ($uploadUrls as $uploadUrl) {
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:carouselalt']);
}
// Media download fallback
return Http::response('fake-image-content', 200);
});
$this->publisher->publish($this->postPlatform);
// The first image carries the user's real alt text (not the filename); the
// second has no alt_text set, so its `altText` key is omitted entirely.
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
$images = data_get($request->data(), 'content.multiImage.images');
return is_array($images)
&& count($images) === 2
&& data_get($images, '0.altText') === 'A sunset over the mountains'
&& ! array_key_exists('altText', $images[1]);
});
});
test('linkedin publisher caps a carousel at the platform max images', function () {
$media = [];
for ($i = 1; $i <= 12; $i++) {
@ -432,6 +672,71 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher never sends altText on a single video post even if the video carries alt text', function () {
$this->post->update([
'media' => [
[
'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-video.mp4',
'meta' => ['alt_text' => 'alt text must not be sent on a video payload'],
],
],
]);
$chunkUploadUrl = 'https://www.linkedin.com/dms/upload/v2/chunk/video/1';
Http::fake(function ($request) use ($chunkUploadUrl) {
$url = $request->url();
if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) {
return Http::response([
'value' => [
'video' => 'urn:li:video:FakeVideoUrn',
'uploadToken' => 'upload-token-abc',
'uploadInstructions' => [
['uploadUrl' => $chunkUploadUrl, 'firstByte' => 0, 'lastByte' => 1023],
],
],
], 200);
}
if ($url === $chunkUploadUrl) {
return Http::response(null, 200, ['etag' => '"etag-abc123"']);
}
if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) {
return Http::response(null, 200);
}
if (str_contains($url, '/rest/videos/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:videonoalt']);
}
return Http::response(str_repeat('x', 1024), 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
$media = data_get($request->data(), 'content.media');
return is_array($media)
&& data_get($media, 'id') === 'urn:li:video:FakeVideoUrn'
&& ! array_key_exists('altText', $media);
});
});
test('linkedin publisher uploads a video across multiple chunks', function () {
$this->post->update([
'media' => [

View file

@ -122,6 +122,191 @@
});
});
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 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 does not send a description for a non-image even if it carries alt text', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/clip.mp4',
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
'meta' => ['alt_text' => 'alt must not be sent for a video'],
],
],
]);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/api/v1/media')) {
return Http::response([
'id' => 'media-video-123',
'type' => 'video',
'url' => 'https://mastodon.social/media/clip.mp4',
], 200);
}
if (str_contains($url, '/api/v1/statuses')) {
return Http::response([
'id' => '109876543211',
'url' => 'https://mastodon.social/@testuser/109876543211',
], 200);
}
return Http::response('fake-video-content', 200);
});
$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;
});
});
test('mastodon publisher includes media ids in post', function () {
Http::fake([
'https://mastodon.social/api/v1/statuses' => Http::response([

View file

@ -405,6 +405,103 @@
});
});
test('pinterest publisher sends alt text from image meta capped to platform max', function () {
$longAlt = str_repeat('x', 600);
$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([
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
$expectedAlt = mb_substr($longAlt, 0, Platform::Pinterest->altTextMaxLength());
Http::assertSent(function ($request) use ($expectedAlt) {
return str_contains($request->url(), '/v5/pins')
&& data_get($request->data(), 'alt_text') === $expectedAlt
&& strlen($expectedAlt) === Platform::Pinterest->altTextMaxLength();
});
});
test('pinterest publisher omits alt_text when image has no alt text 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([
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v5/pins')
&& ! array_key_exists('alt_text', $request->data());
});
});
test('pinterest publisher ignores postPlatform meta alt_text and only reads image alt', function () {
$this->postPlatform->update([
'meta' => [
'board_id' => 'board_123',
'alt_text' => 'legacy per-platform alt text that should be ignored',
],
]);
$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([
'*/v5/pins' => Http::response([
'id' => 'pin_123456',
], 200),
'*' => Http::response('fake-image-content', 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v5/pins')
&& ! array_key_exists('alt_text', $request->data());
});
});
test('pinterest publisher can get boards', function () {
Http::fake([
'*/v5/boards*' => Http::response([

View file

@ -380,6 +380,144 @@
->toThrow(Exception::class, 'Failed to create any carousel items');
});
test('threads 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([
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.threads.net/v1.0/container-123*' => Http::response([
'status' => 'FINISHED',
], 200),
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
'id' => 'post-alt-123',
], 200),
'https://graph.threads.net/v1.0/post-alt-123*' => Http::response([
'permalink' => 'https://www.threads.net/@testuser/post/ALT123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
$expectedAlt = mb_substr($longAlt, 0, Platform::Threads->altTextMaxLength());
Http::assertSent(function ($request) use ($expectedAlt) {
return str_ends_with($request->url(), '/123456789/threads')
&& data_get($request->data(), 'alt_text') === $expectedAlt
&& strlen($expectedAlt) === Platform::Threads->altTextMaxLength();
});
});
test('threads 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([
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.threads.net/v1.0/container-123*' => Http::response([
'status' => 'FINISHED',
], 200),
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
'id' => 'post-no-alt-123',
], 200),
'https://graph.threads.net/v1.0/post-no-alt-123*' => Http::response([
'permalink' => 'https://www.threads.net/@testuser/post/NOALT123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_ends_with($request->url(), '/123456789/threads')
&& ! array_key_exists('alt_text', $request->data());
});
});
test('threads 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([
'https://graph.threads.net/v1.0/123456789/threads' => Http::response([
'id' => 'container-123',
], 200),
'https://graph.threads.net/v1.0/container-123*' => Http::response([
'status' => 'FINISHED',
], 200),
'https://graph.threads.net/v1.0/123456789/threads_publish' => Http::response([
'id' => 'post-carousel-alt-123',
], 200),
'https://graph.threads.net/v1.0/post-carousel-alt-123*' => Http::response([
'permalink' => 'https://www.threads.net/@testuser/post/CAROUSELALT123',
], 200),
]);
$this->publisher->publish($this->postPlatform);
$expectedAlt = mb_substr($imageAlt, 0, Platform::Threads->altTextMaxLength());
Http::assertSent(function ($request) use ($expectedAlt) {
$data = $request->data();
return str_ends_with($request->url(), '/123456789/threads')
&& 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(), '/123456789/threads')
&& data_get($data, 'video_url') !== null
&& ! array_key_exists('alt_text', $data);
});
});
test('threads publisher can publish video with null content', function () {
$this->post->update([
'content' => null,

View file

@ -13,6 +13,7 @@
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\XPublisher;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
@ -319,6 +320,259 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/media/upload'));
});
test('x publisher sends image alt text to the media metadata endpoint', 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',
'meta' => ['alt_text' => 'a red bike'],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'x_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/media/metadata')) {
return Http::response([], 200);
}
if (str_contains($url, '/media/upload')) {
return Http::response(['data' => ['id' => 'media_id_alt_1']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '1212121212', 'text' => 'Hello from X!']], 200);
}
return Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/media/metadata')) {
return false;
}
return data_get($request->data(), 'id') === 'media_id_alt_1'
&& data_get($request->data(), 'metadata.alt_text.text') === 'a red bike';
});
});
test('x publisher truncates alt text to the platform max length', function () {
$longAlt = str_repeat('a', 1200);
$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],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'x_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/media/metadata')) {
return Http::response([], 200);
}
if (str_contains($url, '/media/upload')) {
return Http::response(['data' => ['id' => 'media_id_alt_2']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '1212121213', 'text' => 'Hello from X!']], 200);
}
return Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/media/metadata')) {
return false;
}
return data_get($request->data(), 'metadata.alt_text.text') === str_repeat('a', 1000);
});
});
test('x publisher does not call media metadata 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',
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'x_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/media/upload')) {
return Http::response(['data' => ['id' => 'media_id_alt_3']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '1212121214', 'text' => 'Hello from X!']], 200);
}
return Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
);
});
$this->publisher->publish($this->postPlatform);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media/metadata'));
});
test('x publisher still posts the tweet when the alt text metadata call fails', 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',
'meta' => ['alt_text' => 'a red bike'],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'x_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/media/metadata')) {
throw new ConnectionException('Connection timed out');
}
if (str_contains($url, '/media/upload')) {
return Http::response(['data' => ['id' => 'media_id_alt_fail']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '5551112223', 'text' => 'Hello from X!']], 200);
}
return Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('5551112223');
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets'));
});
test('x publisher does not send alt text metadata for a video even if it carries alt text', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/clip.mp4',
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
],
],
]);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/2/media/upload/initialize')) {
return Http::response(['data' => ['id' => 'video_media_777']], 200);
}
if (str_contains($url, '/append')) {
return Http::response(null, 204);
}
if (str_contains($url, '/finalize')) {
return Http::response(['data' => ['id' => 'video_media_777']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '7778889990', 'text' => 'Hello from X!']], 200);
}
return Http::response('fake-video-content', 200);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('7778889990');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media/metadata'));
});
test('x publisher uploads video via chunked upload', function () {
$this->post->update([
'media' => [

View file

@ -0,0 +1,37 @@
<?php
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 ']]);
expect($item->altText())->toBe('a cat');
});
test('altText is null when meta has no alt_text or it is blank', function () {
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

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
test('altTextMaxLength returns the documented cap for supporting platforms', function () {
expect(Platform::Bluesky->altTextMaxLength())->toBe(2000)
->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)
->and(Platform::Threads->altTextMaxLength())->toBe(1000);
});
test('altTextMaxLength is null for platforms without alt-text support', function () {
expect(Platform::TikTok->altTextMaxLength())->toBeNull()
->and(Platform::Telegram->altTextMaxLength())->toBeNull()
->and(Platform::YouTube->altTextMaxLength())->toBeNull();
});
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();
});
test('altTextMaxLength is defined for every platform so a new case cannot slip through', function () {
foreach (Platform::cases() as $platform) {
$max = $platform->altTextMaxLength();
expect($max === null || (is_int($max) && $max > 0))->toBeTrue();
}
});