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:
commit
3e8b351872
54 changed files with 2722 additions and 54 deletions
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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).'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -378,6 +378,14 @@
|
|||
'add_count' => 'إضافة :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'نص بديل',
|
||||
'edit' => 'تحرير النص البديل',
|
||||
'placeholder' => 'صف هذه الصورة للأشخاص الذين يستخدمون برامج قراءة الشاشة',
|
||||
'hint' => 'يُنشر على المنصات التي تدعم أوصاف الصور.',
|
||||
'save' => 'حفظ',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'البحث عن رمز تعبيري',
|
||||
'empty' => 'لم يتم العثور على رموز تعبيرية',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,14 @@
|
|||
'add_count' => 'Προσθήκη :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Εναλλακτικό κείμενο',
|
||||
'edit' => 'Επεξεργασία εναλλακτικού κειμένου',
|
||||
'placeholder' => 'Περιγράψτε αυτήν την εικόνα για άτομα που χρησιμοποιούν προγράμματα ανάγνωσης οθόνης',
|
||||
'hint' => 'Δημοσιεύεται σε πλατφόρμες που υποστηρίζουν περιγραφές εικόνων.',
|
||||
'save' => 'Αποθήκευση',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Αναζήτηση emoji',
|
||||
'empty' => 'Δεν βρέθηκαν emoji',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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é',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,14 @@
|
|||
'add_count' => ':count 件を追加',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => '代替テキスト',
|
||||
'edit' => '代替テキストを編集',
|
||||
'placeholder' => 'スクリーンリーダーを使用する人のためにこの画像を説明してください',
|
||||
'hint' => '画像の説明に対応しているプラットフォームに公開されます。',
|
||||
'save' => '保存',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => '絵文字を検索',
|
||||
'empty' => '絵文字が見つかりません',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,14 @@
|
|||
'add_count' => ':count개 추가',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => '대체 텍스트',
|
||||
'edit' => '대체 텍스트 편집',
|
||||
'placeholder' => '화면 낭독 프로그램을 사용하는 사람들을 위해 이 이미지를 설명하세요',
|
||||
'hint' => '이미지 설명을 지원하는 플랫폼에 게시됩니다.',
|
||||
'save' => '저장',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => '이모지 검색',
|
||||
'empty' => '이모지를 찾을 수 없습니다',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,14 @@
|
|||
'add_count' => 'Добавить :count',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => 'Альтернативный текст',
|
||||
'edit' => 'Изменить альтернативный текст',
|
||||
'placeholder' => 'Опишите это изображение для людей, использующих программы чтения с экрана',
|
||||
'hint' => 'Публикуется на платформах, поддерживающих описания изображений.',
|
||||
'save' => 'Сохранить',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => 'Поиск эмодзи',
|
||||
'empty' => 'Эмодзи не найдены',
|
||||
|
|
|
|||
|
|
@ -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ı',
|
||||
|
|
|
|||
|
|
@ -378,6 +378,14 @@
|
|||
'add_count' => '添加 :count 个',
|
||||
],
|
||||
|
||||
'alt_text' => [
|
||||
'label' => '替代文本',
|
||||
'edit' => '编辑替代文本',
|
||||
'placeholder' => '为使用屏幕阅读器的用户描述此图片',
|
||||
'hint' => '发布到支持图片描述的平台。',
|
||||
'save' => '保存',
|
||||
],
|
||||
|
||||
'emoji_picker' => [
|
||||
'search' => '搜索表情',
|
||||
'empty' => '未找到表情',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
79
resources/js/components/posts/editor/AltTextDialog.vue
Normal file
79
resources/js/components/posts/editor/AltTextDialog.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ export interface MediaItem {
|
|||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
alt_text?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
243
tests/Browser/AltTextTest.php
Normal file
243
tests/Browser/AltTextTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
141
tests/Feature/PostMediaAltTextValidationTest.php
Normal file
141
tests/Feature/PostMediaAltTextValidationTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
37
tests/Unit/MediaItemAltTextTest.php
Normal file
37
tests/Unit/MediaItemAltTextTest.php
Normal 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();
|
||||
});
|
||||
40
tests/Unit/PlatformAltTextTest.php
Normal file
40
tests/Unit/PlatformAltTextTest.php
Normal 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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue