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.
141 lines
4.9 KiB
PHP
141 lines
4.9 KiB
PHP
<?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');
|
|
});
|