fix(linkedin): align PDF compatibility across editor, MCP schedule, and tests

Review follow-ups before QA:

- Editor: getMediaIncompatibilityReason rejects a PDF on non-document content types, so the schedule gate and variant auto-snap match the backend rule (compliance i18n in en/es/pt-BR)
- MCP UpdatePostTool: validate effective content_type vs stored media on schedule, closing the schedule-without-content_type gap; share entriesForUpdate/errorsFor with the API path
- Tests: Platform allowedMediaTypes contains Document, URL-attach of a PDF (LinkedIn ok / TikTok rejected), multi-platform PDF rejection, document init-failure/missing-URN, Page publisher PROCESSING_FAILED
This commit is contained in:
Paulo Castellano 2026-06-24 17:36:39 -03:00
parent 14e44538a7
commit c339f8cf28
13 changed files with 304 additions and 39 deletions

View file

@ -105,29 +105,13 @@ private function addMediaCompatibilityErrors(Validator $validator): void
$media = $this->has('media') ? (array) $this->input('media', []) : (array) ($post->media ?? []);
$entries = $this->has('platforms')
? collect($this->input('platforms', []))->map(fn ($platform, $index) => [
'key' => "platforms.{$index}.content_type",
'content_type' => data_get($platform, 'content_type')
?? $post->postPlatforms()->where('id', data_get($platform, 'id'))->first()?->content_type?->value,
])
: $post->postPlatforms()->where('enabled', true)->get()->values()->map(fn ($postPlatform, $index) => [
'key' => "platforms.{$index}.content_type",
'content_type' => $postPlatform->content_type?->value,
]);
$entries = ContentTypeCompatibleWithMedia::entriesForUpdate(
$post,
$this->has('platforms') ? (array) $this->input('platforms', []) : null,
);
foreach ($entries as $entry) {
if (data_get($entry, 'content_type') === null) {
continue;
}
(new ContentTypeCompatibleWithMedia($media))->validate(
$entry['key'],
(string) $entry['content_type'],
function (string $message) use ($validator, $entry): void {
$validator->errors()->add($entry['key'], $message);
},
);
foreach (ContentTypeCompatibleWithMedia::errorsFor($entries, $media) as $key => $message) {
$validator->errors()->add($key, $message);
}
}

View file

@ -16,6 +16,7 @@
use App\Support\PostStatusRules;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@ -36,9 +37,6 @@ public function handle(Request $request): Response|ResponseFactory
return Response::error('Post not found.');
}
$enforcesMediaCompatibility = data_get($request->all(), 'status') === Status::Scheduled->value;
$storedMedia = (array) ($post->media ?? []);
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'content' => ['nullable', 'string', 'max:10000'],
@ -57,11 +55,25 @@ public function handle(Request $request): Response|ResponseFactory
'string',
Rule::in(array_column(ContentType::cases(), 'value')),
new ContentTypeMatchesPostPlatform,
Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia($storedMedia)]),
],
...PostPlatformMetaRules::rules(),
]);
// On schedule, validate each platform's effective content_type (resubmitted
// here, or stored) against the post's stored media — the tool can't change
// media, so a misconfigured post can't be scheduled even without resubmitting
// content_type. Mirrors the public API's withValidator check.
if (data_get($validated, 'status') === Status::Scheduled->value) {
$errors = ContentTypeCompatibleWithMedia::errorsFor(
ContentTypeCompatibleWithMedia::entriesForUpdate($post, data_get($validated, 'platforms')),
(array) ($post->media ?? []),
);
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
$payload = collect($validated)->except('post_id')->all();
$result = UpdatePost::execute($workspace, $post, $payload);

View file

@ -48,24 +48,73 @@ public function setData(array $data): static
*/
public static function assertStoredPostCompatible(Post $post): void
{
$media = (array) ($post->media ?? []);
$errors = [];
foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) {
(new self($media))->validate(
"platforms.{$index}.content_type",
(string) $postPlatform->content_type?->value,
function (string $message) use (&$errors, $index): void {
$errors["platforms.{$index}.content_type"] = $message;
},
);
}
$errors = self::errorsFor(
self::entriesForUpdate($post, null),
(array) ($post->media ?? []),
);
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
/**
* The per-platform entries to validate for a post update: each platform's
* effective content_type (resubmitted in this request, else its stored
* value), keyed by the error path the caller surfaces. When $requestPlatforms
* is null, the post's currently-enabled platforms are used.
*
* @param array<int, mixed>|null $requestPlatforms
* @return array<int, array{key: string, content_type: string|null}>
*/
public static function entriesForUpdate(Post $post, ?array $requestPlatforms): array
{
if (is_array($requestPlatforms)) {
return collect($requestPlatforms)->map(fn ($platform, $index): array => [
'key' => "platforms.{$index}.content_type",
'content_type' => data_get($platform, 'content_type')
?? $post->postPlatforms()->where('id', data_get($platform, 'id'))->first()?->content_type?->value,
])->all();
}
return $post->postPlatforms()->where('enabled', true)->get()->values()
->map(fn ($postPlatform, $index): array => [
'key' => "platforms.{$index}.content_type",
'content_type' => $postPlatform->content_type?->value,
])->all();
}
/**
* Validate a set of platform entries against the given media, returning
* `[errorKey => message]` for each incompatible content_type.
*
* @param array<int, array{key: string, content_type: string|null}> $entries
* @param array<int, mixed> $media
* @return array<string, string>
*/
public static function errorsFor(array $entries, array $media): array
{
$errors = [];
foreach ($entries as $entry) {
$contentType = data_get($entry, 'content_type');
if ($contentType === null) {
continue;
}
(new self($media))->validate(
$entry['key'],
(string) $contentType,
function (string $message) use (&$errors, $entry): void {
$errors[$entry['key']] = $message;
},
);
}
return $errors;
}
/**
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/

View file

@ -347,9 +347,11 @@
'no_images' => 'Only videos are allowed for this format.',
'no_mixed_media' => "Images and videos can't be combined in the same post.",
'no_gifs' => 'GIFs are not supported here.',
'no_documents' => 'PDF documents are not supported by this format.',
'video_too_large' => 'Video exceeds the size limit for this platform.',
'video_too_long' => 'Video must be under :seconds seconds for this format.',
'image_too_large' => 'Image exceeds the size limit for this platform.',
'document_too_large' => 'PDF exceeds the size limit for this platform.',
'aspect_ratio_invalid' => 'Aspect ratio is not supported by this format.',
'no_content_type' => 'Pick a content type for this platform.',
],

View file

@ -347,9 +347,11 @@
'no_images' => 'Solo se permiten videos en este formato.',
'no_mixed_media' => 'No se pueden combinar imágenes y videos en la misma publicación.',
'no_gifs' => 'Los GIFs no son compatibles aquí.',
'no_documents' => 'Los documentos PDF no son compatibles con este formato.',
'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.',
'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.',
'image_too_large' => 'La imagen supera el límite de tamaño de esta plataforma.',
'document_too_large' => 'El PDF supera el límite de tamaño de esta plataforma.',
'aspect_ratio_invalid' => 'La proporción de aspecto no es compatible con este formato.',
'no_content_type' => 'Elige un tipo de contenido para esta plataforma.',
],

View file

@ -347,9 +347,11 @@
'no_images' => 'Apenas vídeos são permitidos neste formato.',
'no_mixed_media' => 'Não é possível combinar imagem e vídeo na mesma publicação.',
'no_gifs' => 'GIFs não são suportados aqui.',
'no_documents' => 'Documentos PDF não são suportados por este formato.',
'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.',
'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.',
'image_too_large' => 'Imagem excede o limite de tamanho desta plataforma.',
'document_too_large' => 'O PDF excede o limite de tamanho desta plataforma.',
'aspect_ratio_invalid' => 'A proporção da imagem não é suportada por este formato.',
'no_content_type' => 'Escolha um tipo de conteúdo para esta plataforma.',
],

View file

@ -92,6 +92,7 @@ export const getMediaIncompatibilityReason = (
): string | null => {
const rules = getMediaRulesForContentType(contentType);
const videos = mediaItems.filter((m) => m.type === 'video' || m.mime_type?.startsWith('video/'));
const documents = mediaItems.filter((m) => m.type === 'document' || m.mime_type === 'application/pdf');
const images = mediaItems.filter((m) => m.type === 'image' || m.mime_type?.startsWith('image/'));
const gifs = mediaItems.filter((m) => m.mime_type === 'image/gif');
const total = mediaItems.length;
@ -99,6 +100,7 @@ export const getMediaIncompatibilityReason = (
if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media');
if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos');
if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images');
if (!rules.acceptDocuments && documents.length > 0) return trans('posts.edit.compliance.no_documents');
if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) return trans('posts.edit.compliance.no_mixed_media');
if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs');
if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) });
@ -106,11 +108,17 @@ export const getMediaIncompatibilityReason = (
for (const m of mediaItems) {
const isVideo = m.type === 'video' || m.mime_type?.startsWith('video/');
const isDocument = m.type === 'document' || m.mime_type === 'application/pdf';
const size = m.size ?? 0;
const duration = m.meta?.duration ?? 0;
const width = m.meta?.width ?? 0;
const height = m.meta?.height ?? 0;
if (isDocument) {
if (rules.maxDocumentBytes && size > 0 && size > rules.maxDocumentBytes) return trans('posts.edit.compliance.document_too_large');
continue;
}
if (isVideo) {
if (rules.maxVideoBytes && size > 0 && size > rules.maxVideoBytes) return trans('posts.edit.compliance.video_too_large');
if (rules.maxVideoDurationSec && duration > 0 && duration > rules.maxVideoDurationSec) {

View file

@ -197,6 +197,41 @@
->assertJsonValidationErrors(['platforms.0.content_type']);
});
it('rejects only the platform that cannot take a PDF in a multi-platform post', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$x = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::X]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Deck',
'media' => [[
'id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf',
'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
$linkedinPlatform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
]);
$xPlatform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $x->id,
'platform' => Platform::X, 'content_type' => ContentType::XPost, 'enabled' => true,
]);
$this->withHeaders($this->headers)
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Publishing->value,
'platforms' => [
['id' => $linkedinPlatform->id, 'content_type' => ContentType::LinkedInDocument->value],
['id' => $xPlatform->id, 'content_type' => ContentType::XPost->value],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['platforms.1.content_type'])
->assertJsonMissingValidationErrors(['platforms.0.content_type']);
});
it('persists per-platform meta across networks on store', function () {
$instagram = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]);
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);

View file

@ -2,11 +2,15 @@
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
use App\Mcp\Tools\Post\AttachMediaFromUrlTool;
use App\Models\Media;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
@ -90,6 +94,60 @@
expect($this->post->fresh()->media)->toHaveCount(1);
});
test('attaches a pdf document from a url to a LinkedIn post', 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'],
),
]);
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
PostPlatform::factory()->create([
'post_id' => $this->post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.com/deck.pdf'],
]);
$response->assertOk();
$media = $this->post->fresh()->media;
expect($media)->toHaveCount(1)
->and($media[0]['type'])->toBe('document');
});
test('rejects a pdf url for a post with no PDF-capable platform', 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'],
),
]);
$tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]);
PostPlatform::factory()->create([
'post_id' => $this->post->id, 'social_account_id' => $tiktok->id,
'platform' => Platform::TikTok, 'content_type' => ContentType::TikTokVideo, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)
->tool(AttachMediaFromUrlTool::class, [
'post_id' => $this->post->id,
'urls' => ['https://example.com/deck.pdf'],
]);
$response->assertOk();
expect($this->post->fresh()->media)->toHaveCount(0);
});
test('post 404 from another workspace', function () {
$other = Workspace::factory()->create();
$post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]);

View file

@ -266,6 +266,34 @@
$response->assertHasErrors(['Document does not support images.']);
});
test('update post rejects scheduling a misconfigured LinkedIn post without resubmitting content_type', function () {
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'media' => [[
'id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf',
'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id, 'social_account_id' => $linkedin->id,
'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true,
]);
$response = TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, [
'post_id' => $post->id,
'status' => PostStatus::Scheduled->value,
'scheduled_at' => now()->addDay()->toIso8601String(),
'platforms' => [['id' => $platform->id]],
]);
$response->assertHasErrors(['Post does not support PDF documents.']);
});
test('publish post succeeds for a Discord platform with a channel', function () {
Queue::fake();

View file

@ -346,3 +346,39 @@
&& data_get($request->data(), 'content.media.title') === 'Company Deck';
});
});
test('linkedin page publisher throws and does not post when document processing fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInPageDocument]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/company-deck.pdf',
'url' => 'https://example.com/media/2026-01/company-deck.pdf',
'mime_type' => 'application/pdf', 'original_filename' => 'company-deck.pdf',
]],
]);
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/org/fail';
Http::fake(function ($request) use ($uploadUrl) {
$url = $request->url();
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:OrgFail']], 200);
}
if ($url === $uploadUrl) {
return Http::response(null, 201);
}
if (str_contains($url, '/rest/documents/')) {
return Http::response(['status' => 'PROCESSING_FAILED'], 200);
}
return Http::response('fake-pdf-bytes', 200);
});
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'LinkedIn Page document processing failed');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});

View file

@ -578,3 +578,50 @@
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher throws and does not post when document init fails', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
'url' => 'https://example.com/media/2026-01/deck.pdf',
'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
Http::fake(function ($request) {
if (str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload')) {
return Http::response(['message' => 'Invalid request', 'status' => 400], 400);
}
return Http::response('fake-pdf-bytes', 200);
});
expect(fn () => $this->publisher->publish($this->postPlatform))->toThrow(Exception::class);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher throws when document init response is missing the urn', function () {
$this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]);
$this->post->update([
'media' => [[
'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf',
'url' => 'https://example.com/media/2026-01/deck.pdf',
'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf',
]],
]);
Http::fake(function ($request) {
if (str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload')) {
return Http::response(['value' => []], 200); // no uploadUrl / document
}
return Http::response('fake-pdf-bytes', 200);
});
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'missing uploadUrl or document URN');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});

View file

@ -35,8 +35,10 @@
});
test('platform has correct allowed media types', function () {
expect(Platform::LinkedIn->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video);
expect(Platform::LinkedIn->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video, MediaType::Document);
expect(Platform::LinkedInPage->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video, MediaType::Document);
expect(Platform::X->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video);
expect(Platform::X->allowedMediaTypes())->not->toContain(MediaType::Document);
expect(Platform::TikTok->allowedMediaTypes())->toBe([MediaType::Video]);
expect(Platform::YouTube->allowedMediaTypes())->toBe([MediaType::Video]);
expect(Platform::Instagram->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video);