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.
311 lines
13 KiB
PHP
311 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\PostPlatform\ContentType;
|
|
use App\Enums\SocialAccount\Platform;
|
|
use App\Exceptions\PlatformUnavailableException;
|
|
use App\Exceptions\Social\DiscordPublishException;
|
|
use App\Models\Post;
|
|
use App\Models\PostPlatform;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Media\MediaOptimizer;
|
|
use App\Services\Social\Discord\DiscordPublisher;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
beforeEach(function () {
|
|
Cache::flush(); // channel list is cached per guild — isolate between tests
|
|
config([
|
|
'trypost.platforms.discord.bot_token' => 'BOTTOKEN',
|
|
'services.discord.client_id' => '999000111', // bot user id, used by the channel permission check
|
|
]);
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
$this->socialAccount = SocialAccount::factory()->discord()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform_user_id' => '111222333', // guild id
|
|
]);
|
|
|
|
$this->post = Post::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'user_id' => $this->user->id,
|
|
'content' => 'Hello Discord',
|
|
]);
|
|
|
|
$this->makePostPlatform = fn (array $meta = ['channel_id' => '444555666']) => PostPlatform::factory()->create([
|
|
'post_id' => $this->post->id,
|
|
'social_account_id' => $this->socialAccount->id,
|
|
'platform' => Platform::Discord,
|
|
'content_type' => ContentType::DiscordMessage,
|
|
'meta' => $meta,
|
|
]);
|
|
|
|
$this->publisher = new DiscordPublisher;
|
|
});
|
|
|
|
function fakeDiscord(array $messageResponse = ['id' => '777'], int $status = 200): void
|
|
{
|
|
Http::fake([
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([
|
|
['id' => '444555666', 'name' => 'general', 'type' => 0],
|
|
], 200),
|
|
// @everyone (role id == guild id) grants VIEW_CHANNEL + SEND_MESSAGES (1024 + 2048).
|
|
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),
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response($messageResponse, $status),
|
|
]);
|
|
}
|
|
|
|
test('publishes a text-only message and suppresses accidental pings', function () {
|
|
fakeDiscord();
|
|
|
|
$result = $this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
expect($result['id'])->toBe('777');
|
|
expect($result['url'])->toBe('https://discord.com/channels/111222333/444555666/777');
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false; // only the message POST may satisfy this assertion
|
|
}
|
|
|
|
return $request->hasHeader('Authorization', 'Bot BOTTOKEN')
|
|
&& data_get($request->data(), 'content') === 'Hello Discord'
|
|
// No mention chips → parse is empty, so typed "@everyone" never pings.
|
|
&& data_get($request->data(), 'allowed_mentions.parse') === [];
|
|
});
|
|
});
|
|
|
|
test('builds allowed_mentions from explicit mention chips only', function () {
|
|
fakeDiscord();
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)([
|
|
'channel_id' => '444555666',
|
|
'mentions' => [
|
|
['token' => '@everyone', 'label' => '@everyone'],
|
|
['token' => '<@&10>', 'label' => '@moderators'],
|
|
],
|
|
]));
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false;
|
|
}
|
|
$allowed = data_get($request->data(), 'allowed_mentions');
|
|
|
|
return data_get($allowed, 'parse') === ['everyone']
|
|
&& data_get($allowed, 'roles') === ['10']
|
|
&& data_get($request->data(), 'content') === "Hello Discord\n\n@everyone <@&10>";
|
|
});
|
|
});
|
|
|
|
test('includes rich embeds with clamped color', function () {
|
|
fakeDiscord(['id' => '888']);
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)([
|
|
'channel_id' => '444555666',
|
|
'embeds' => [[
|
|
'title' => 'Release v2',
|
|
'description' => 'Shipped today',
|
|
'color' => '#5865F2',
|
|
'image' => 'https://example.com/banner.png',
|
|
]],
|
|
]));
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false;
|
|
}
|
|
$embed = data_get($request->data(), 'embeds.0');
|
|
|
|
return data_get($embed, 'title') === 'Release v2'
|
|
&& data_get($embed, 'color') === hexdec('5865F2')
|
|
&& data_get($embed, 'image.url') === 'https://example.com/banner.png';
|
|
});
|
|
});
|
|
|
|
test('uploads media as a multipart attachment', function () {
|
|
$this->post->update([
|
|
'media' => [[
|
|
'id' => 'm1',
|
|
'path' => 'media/2026-01/pic.jpg',
|
|
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
|
'mime_type' => 'image/jpeg',
|
|
'original_filename' => 'pic.jpg',
|
|
]],
|
|
]);
|
|
|
|
$this->mock(MediaOptimizer::class)
|
|
->shouldReceive('optimizeImage')
|
|
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '901'], 200),
|
|
]);
|
|
|
|
$result = $this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
expect($result['id'])->toBe('901');
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false;
|
|
}
|
|
$names = collect($request->data())->pluck('name');
|
|
|
|
return $names->contains('payload_json') && $names->contains('files[0]');
|
|
});
|
|
});
|
|
|
|
test('sets the attachment description from image alt text, capped at the platform max', function () {
|
|
$longAlt = str_repeat('a', 2000);
|
|
|
|
$this->post->update([
|
|
'media' => [[
|
|
'id' => 'm1',
|
|
'path' => 'media/2026-01/pic.jpg',
|
|
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
|
'mime_type' => 'image/jpeg',
|
|
'original_filename' => 'pic.jpg',
|
|
'meta' => ['alt_text' => $longAlt],
|
|
]],
|
|
]);
|
|
|
|
$this->mock(MediaOptimizer::class)
|
|
->shouldReceive('optimizeImage')
|
|
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '902'], 200),
|
|
]);
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
$expectedAlt = mb_substr($longAlt, 0, Platform::Discord->altTextMaxLength());
|
|
|
|
Http::assertSent(function ($request) use ($expectedAlt) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false;
|
|
}
|
|
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
|
|
$payload = json_decode(data_get($payloadPart, 'contents'), true);
|
|
|
|
return data_get($payload, 'attachments.0.description') === $expectedAlt
|
|
&& mb_strlen($expectedAlt) === Platform::Discord->altTextMaxLength();
|
|
});
|
|
});
|
|
|
|
test('omits the attachment description when the image has no alt text', function () {
|
|
$this->post->update([
|
|
'media' => [[
|
|
'id' => 'm1',
|
|
'path' => 'media/2026-01/pic.jpg',
|
|
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
|
'mime_type' => 'image/jpeg',
|
|
'original_filename' => 'pic.jpg',
|
|
]],
|
|
]);
|
|
|
|
$this->mock(MediaOptimizer::class)
|
|
->shouldReceive('optimizeImage')
|
|
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '903'], 200),
|
|
]);
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false;
|
|
}
|
|
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
|
|
$payload = json_decode(data_get($payloadPart, 'contents'), true);
|
|
|
|
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
|
|
});
|
|
});
|
|
|
|
test('does not set a description on a non-image attachment even if it carries alt text', function () {
|
|
$this->post->update([
|
|
'media' => [[
|
|
'id' => 'm1',
|
|
'path' => 'media/2026-01/clip.mp4',
|
|
'url' => 'https://example.com/media/2026-01/clip.mp4',
|
|
'mime_type' => 'video/mp4',
|
|
'original_filename' => 'clip.mp4',
|
|
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
|
|
]],
|
|
]);
|
|
|
|
Http::fake([
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '904'], 200),
|
|
]);
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
Http::assertSent(function ($request) {
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
return false;
|
|
}
|
|
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
|
|
$payload = json_decode(data_get($payloadPart, 'contents'), true);
|
|
|
|
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
|
|
});
|
|
});
|
|
|
|
test('throws when no channel is selected', function () {
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(meta: [])))
|
|
->toThrow(DiscordPublishException::class);
|
|
});
|
|
|
|
test('throws when the channel is not part of the guild', function () {
|
|
fakeDiscord();
|
|
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(['channel_id' => '999999999'])))
|
|
->toThrow(DiscordPublishException::class);
|
|
});
|
|
|
|
test('retries (does not permanently fail) when the channel lookup is transiently down', function () {
|
|
Http::fake([
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response('upstream down', 500),
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '777'], 200),
|
|
]);
|
|
|
|
// A 5xx on the channel guard must surface as PlatformUnavailableException so
|
|
// the publish job reschedules — not a permanent "channel not in server" fail.
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)()))
|
|
->toThrow(PlatformUnavailableException::class);
|
|
});
|
|
|
|
test('maps a missing-permission error to a publish exception', function () {
|
|
fakeDiscord(['code' => 50013, 'message' => 'Missing Permissions'], 403);
|
|
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)()))
|
|
->toThrow(DiscordPublishException::class);
|
|
});
|