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:
parent
a1f2fea1bc
commit
cf045f2cdb
23 changed files with 788 additions and 46 deletions
|
|
@ -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],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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)];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue