Harden per-image alt text across publishers, validation, and attach paths

Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
This commit is contained in:
Paulo Castellano 2026-07-16 13:55:33 -03:00
parent a1f2fea1bc
commit cf045f2cdb
23 changed files with 788 additions and 46 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -121,12 +121,13 @@ 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' => array_filter([
'id' => $mediaUrn,
'altText' => $media->first()->altTextFor($this->platform()),
'altText' => $item->isImage() ? $item->altTextFor($this->platform()) : null,
], fn ($v) => $v !== null)];
}
}

View file

@ -129,7 +129,7 @@ private function sendWithMedia(string $channelId, array $payload, Collection $me
$attachment = ['id' => $index, 'filename' => $filename];
$alt = $item->altTextFor(Platform::Discord);
$alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null;
if ($alt !== null) {
$attachment['description'] = $alt;

View file

@ -33,7 +33,7 @@ public function publish(PostPlatform $postPlatform): array
// Upload media first (max 4)
foreach ($medias->take(4) as $media) {
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->altTextFor(Platform::Mastodon));
$mediaId = $this->uploadMedia($account, $instance, $media->url, $media->original_filename, $media->isImage() ? $media->altTextFor(Platform::Mastodon) : null);
if ($mediaId) {
$mediaIds[] = $mediaId;
}

View file

@ -16,6 +16,7 @@
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class XPublisher
{
@ -110,24 +111,37 @@ private function getHttpClient(): PendingRequest
/**
* Sets the image's accessibility description on X via the v2 media
* metadata endpoint. Skipped entirely when no alt text was provided.
* 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;
}
$this->getHttpClient()->post("{$this->baseUrl}/media/metadata", [
'id' => $mediaId,
'metadata' => [
'alt_text' => [
'text' => $alt,
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

View file

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

View file

@ -100,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

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@ -21,6 +21,9 @@ const emit = defineEmits<{
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 ?? '';
@ -28,6 +31,10 @@ watch(open, (isOpen) => {
});
const save = () => {
if (isOverLimit.value) {
return;
}
emit('save', value.value);
open.value = false;
};
@ -50,11 +57,17 @@ const save = () => {
rows="4"
data-testid="alt-text-input"
/>
<p class="text-right text-xs tabular-nums text-foreground/60">{{ value.length }} / {{ MAX_ALT_TEXT_LENGTH }}</p>
<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" @click="save">
<Button data-testid="alt-text-save" :disabled="isOverLimit" @click="save">
{{ $t('posts.edit.alt_text.save') }}
</Button>
<Button variant="outline" @click="open = false">

View file

@ -83,7 +83,7 @@ const openPreview = (item: MediaItem) => {
media.value.map((m) => ({
url: m.url,
type: classify(m) ?? MediaType.Image,
altText: m.meta?.alt_text,
altText: isImage(m) ? m.meta?.alt_text : undefined,
})),
idx,
);

View file

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

View file

@ -74,6 +74,29 @@ function waitForLightboxAltText(mixed $page): void
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]);
@ -114,3 +137,107 @@ function waitForLightboxAltText(mixed $page): void
$page->assertSeeIn('@lightbox-alt-text', 'a golden retriever on a beach');
});
test('lightbox shows no alt overlay for a non-image even when meta carries alt text', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'v1',
'type' => 'video',
'mime_type' => 'video/mp4',
'path' => 'uploads/clip.mp4',
'url' => 'https://cdn.test/clip.mp4',
'meta' => ['alt_text' => 'alt that must never overlay a video'],
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@media-thumbnail');
$page->script(<<<'JS'
(async () => {
for (let attempt = 0; attempt < 100; attempt++) {
if (document.querySelector('[data-testid="lightbox-video"]')) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
})();
JS);
$page->assertPresent('@lightbox-video')
->assertMissing('@lightbox-alt-text');
});
test('alt text dialog blocks saving when the description exceeds the limit', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@alt-text-button')
->fill('@alt-text-input', str_repeat('a', 2001));
waitForSaveButton($page, disabled: true);
$page->assertDisabled('@alt-text-save')
->fill('@alt-text-input', 'a short and valid description');
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
$page->fill('@alt-text-input', str_repeat('a', 1999).str_repeat(' ', 100));
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
});
test('alt text dialog counts emoji by code point, not UTF-16 units', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'hello',
'media' => [[
'id' => 'm1',
'type' => 'image',
'mime_type' => 'image/png',
'path' => 'uploads/x.png',
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
]],
]);
$this->actingAs($user);
$page = visit(route('app.posts.edit', $post));
$page->click('@alt-text-button')
->fill('@alt-text-input', str_repeat('😀', 1500));
waitForSaveButton($page, disabled: false);
$page->assertEnabled('@alt-text-save');
});

View file

@ -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]);
@ -489,6 +576,33 @@
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();
@ -508,7 +622,7 @@
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['media.0.meta.alt_text']);
->assertJsonValidationErrors(['media.0.meta']);
expect(Post::where('content', 'Alt text too long post')->exists())->toBeFalse();
});

View file

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

View file

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

View file

@ -31,6 +31,43 @@
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]);
@ -51,5 +88,54 @@
]],
]);
$response->assertSessionHasErrors('media.0.meta.alt_text');
$response->assertSessionHasErrors('media.0.meta');
});
test('media alt_text at exactly 2000 chars is accepted', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$altText = str_repeat('a', 2000);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg',
'meta' => ['alt_text' => $altText],
]],
]);
$response->assertSessionDoesntHaveErrors();
expect($post->fresh()->media[0]['meta']['alt_text'])->toBe($altText);
});
test('non-string media alt_text is rejected', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$workspace->members()->attach($user->id, ['role' => Role::Member->value]);
$user->update(['current_workspace_id' => $workspace->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
$response = $this->actingAs($user)->put(route('app.posts.update', $post), [
'status' => 'draft',
'content' => 'hi',
'media' => [[
'id' => 'm1', 'path' => 'uploads/x.jpg', 'url' => 'https://cdn.test/x.jpg',
'meta' => ['alt_text' => ['not', 'a', 'string']],
]],
]);
$response->assertSessionHasErrors('media.0.meta');
});

View file

@ -246,6 +246,39 @@ function fakeDiscord(array $messageResponse = ['id' => '777'], int $status = 200
});
});
test('does not set a description on a non-image attachment even if it carries alt text', function () {
$this->post->update([
'media' => [[
'id' => 'm1',
'path' => 'media/2026-01/clip.mp4',
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
]],
]);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '904'], 200),
]);
$this->publisher->publish(($this->makePostPlatform)());
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/messages')) {
return false;
}
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
$payload = json_decode(data_get($payloadPart, 'contents'), true);
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
});
});
test('throws when no channel is selected', function () {
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(meta: [])))
->toThrow(DiscordPublishException::class);

View file

@ -672,6 +672,71 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher never sends altText on a single video post even if the video carries alt text', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
'meta' => ['alt_text' => 'alt text must not be sent on a video payload'],
],
],
]);
$chunkUploadUrl = 'https://www.linkedin.com/dms/upload/v2/chunk/video/1';
Http::fake(function ($request) use ($chunkUploadUrl) {
$url = $request->url();
if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) {
return Http::response([
'value' => [
'video' => 'urn:li:video:FakeVideoUrn',
'uploadToken' => 'upload-token-abc',
'uploadInstructions' => [
['uploadUrl' => $chunkUploadUrl, 'firstByte' => 0, 'lastByte' => 1023],
],
],
], 200);
}
if ($url === $chunkUploadUrl) {
return Http::response(null, 200, ['etag' => '"etag-abc123"']);
}
if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) {
return Http::response(null, 200);
}
if (str_contains($url, '/rest/videos/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:videonoalt']);
}
return Http::response(str_repeat('x', 1024), 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/rest/posts')) {
return false;
}
$media = data_get($request->data(), 'content.media');
return is_array($media)
&& data_get($media, 'id') === 'urn:li:video:FakeVideoUrn'
&& ! array_key_exists('altText', $media);
});
});
test('linkedin publisher uploads a video across multiple chunks', function () {
$this->post->update([
'media' => [

View file

@ -261,6 +261,52 @@
@unlink($optimizedFile);
});
test('mastodon publisher does not send a description for a non-image even if it carries alt text', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/clip.mp4',
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
'meta' => ['alt_text' => 'alt must not be sent for a video'],
],
],
]);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/api/v1/media')) {
return Http::response([
'id' => 'media-video-123',
'type' => 'video',
'url' => 'https://mastodon.social/media/clip.mp4',
], 200);
}
if (str_contains($url, '/api/v1/statuses')) {
return Http::response([
'id' => '109876543211',
'url' => 'https://mastodon.social/@testuser/109876543211',
], 200);
}
return Http::response('fake-video-content', 200);
});
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/api/v1/media')) {
return false;
}
return collect($request->data())->firstWhere('name', 'description') === null;
});
});
test('mastodon publisher includes media ids in post', function () {
Http::fake([
'https://mastodon.social/api/v1/statuses' => Http::response([

View file

@ -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 () {
@ -479,6 +480,99 @@
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media/metadata'));
});
test('x publisher still posts the tweet when the alt text metadata call fails', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
'meta' => ['alt_text' => 'a red bike'],
],
],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'x_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/media/metadata')) {
throw new ConnectionException('Connection timed out');
}
if (str_contains($url, '/media/upload')) {
return Http::response(['data' => ['id' => 'media_id_alt_fail']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '5551112223', 'text' => 'Hello from X!']], 200);
}
return Http::response(
file_get_contents(__DIR__.'/../../../fixtures/1x1.png'),
200,
['Content-Type' => 'image/png'],
);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('5551112223');
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets'));
});
test('x publisher does not send alt text metadata for a video even if it carries alt text', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/clip.mp4',
'url' => 'https://example.com/media/2026-01/clip.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'clip.mp4',
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
],
],
]);
Http::fake(function ($request) {
$url = $request->url();
if (str_contains($url, '/2/media/upload/initialize')) {
return Http::response(['data' => ['id' => 'video_media_777']], 200);
}
if (str_contains($url, '/append')) {
return Http::response(null, 204);
}
if (str_contains($url, '/finalize')) {
return Http::response(['data' => ['id' => 'video_media_777']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '7778889990', 'text' => 'Hello from X!']], 200);
}
return Http::response('fake-video-content', 200);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('7778889990');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media/metadata'));
});
test('x publisher uploads video via chunked upload', function () {
$this->post->update([
'media' => [

View file

@ -30,3 +30,11 @@
->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();
}
});