From 14e44538a78ea8d6be91c419fb7f535fc8710c8b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 24 Jun 2026 17:08:48 -0300 Subject: [PATCH] feat(linkedin): enforce PDF/media compatibility on API and MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the document (PDF) exclusivity validation — previously web-only — also apply when scheduling/publishing via the public API and MCP, so a misconfigured post can't slip through these entry points. - ContentTypeCompatibleWithMedia: stored-media fallback for partial updates + a stored-state assertStoredPostCompatible(Post) - MCP PublishPostTool: assert stored-state compatibility before publish (the media-side mirror of assertStoredPostPublishable) - API UpdatePostRequest: validate each platform's effective content_type against effective media on schedule/publish (covers publishing without resubmitting content_type) - MCP UpdatePostTool: apply the rule on schedule with stored-media fallback - Tests: API + MCP happy + rejection paths, plus rule fallback/precedence units --- app/DataTransferObjects/MediaItem.php | 3 - .../Requests/Api/Post/UpdatePostRequest.php | 48 +++++++ app/Mcp/Tools/Post/PublishPostTool.php | 2 + app/Mcp/Tools/Post/UpdatePostTool.php | 12 +- app/Rules/ContentTypeCompatibleWithMedia.php | 44 +++++- tests/Feature/Api/PostApiPlatformMetaTest.php | 129 ++++++++++++++++++ .../Feature/Mcp/PostPlatformMetaToolTest.php | 91 ++++++++++++ .../ContentTypeCompatibleWithMediaTest.php | 34 +++++ 8 files changed, 358 insertions(+), 5 deletions(-) diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index 1e3fd111..b85f2741 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -52,9 +52,6 @@ public function isDocument(): bool return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf'; } - /** - * @param array $data - */ /** * @param array $data */ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 7d3ef18e..47a7ada1 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -10,6 +10,7 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Rules\ContentFitsPlatformLimits; +use App\Rules\ContentTypeCompatibleWithMedia; use App\Rules\ContentTypeMatchesPostPlatform; use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; @@ -73,6 +74,8 @@ public function withValidator(Validator $validator): void return; } + $this->addMediaCompatibilityErrors($validator); + $platformsById = $this->resolveSelectedPlatforms(); PostPlatformMetaRules::addRequiredOnPublishErrors( @@ -83,6 +86,51 @@ public function withValidator(Validator $validator): void }); } + /** + * On publish/schedule, validate every platform's *effective* content_type + * (resubmitted in this request, or its stored value) against the *effective* + * media (the request's media when sent, otherwise the post's stored media). + * This closes the gap where a client publishes a misconfigured post — e.g. a + * PDF on a regular LinkedIn post — without resubmitting content_type, which a + * field-level rule on `platforms.*.content_type` would skip. + */ + private function addMediaCompatibilityErrors(Validator $validator): void + { + $routePost = $this->route('post'); + $post = $routePost instanceof Post ? $routePost : Post::find($routePost); + + if (! $post) { + return; + } + + $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, + ]); + + 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); + }, + ); + } + } + /** * @return Collection */ diff --git a/app/Mcp/Tools/Post/PublishPostTool.php b/app/Mcp/Tools/Post/PublishPostTool.php index da2feac9..00e0990d 100644 --- a/app/Mcp/Tools/Post/PublishPostTool.php +++ b/app/Mcp/Tools/Post/PublishPostTool.php @@ -9,6 +9,7 @@ use App\Enums\Post\Status; use App\Http\Resources\Api\PostResource; use App\Models\Post; +use App\Rules\ContentTypeCompatibleWithMedia; use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -43,6 +44,7 @@ public function handle(Request $request): Response|ResponseFactory } PostPlatformMetaRules::assertStoredPostPublishable($post); + ContentTypeCompatibleWithMedia::assertStoredPostCompatible($post); $scheduledAt = data_get($validated, 'scheduled_at'); diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 055b5942..0e7832ce 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -10,6 +10,7 @@ use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Models\Post; +use App\Rules\ContentTypeCompatibleWithMedia; use App\Rules\ContentTypeMatchesPostPlatform; use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; @@ -35,6 +36,9 @@ 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'], @@ -48,7 +52,13 @@ public function handle(Request $request): Response|ResponseFactory 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $post->id), ], - 'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform], + 'platforms.*.content_type' => [ + 'sometimes', + 'string', + Rule::in(array_column(ContentType::cases(), 'value')), + new ContentTypeMatchesPostPlatform, + Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia($storedMedia)]), + ], ...PostPlatformMetaRules::rules(), ]); diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 1c3e0313..99c0849a 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -6,10 +6,12 @@ use App\Enums\Media\Type as MediaType; use App\Enums\PostPlatform\ContentType; +use App\Models\Post; use Closure; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Translation\PotentiallyTranslatedString; +use Illuminate\Validation\ValidationException; class ContentTypeCompatibleWithMedia implements DataAwareRule, ValidationRule { @@ -18,6 +20,14 @@ class ContentTypeCompatibleWithMedia implements DataAwareRule, ValidationRule */ private array $data = []; + /** + * @param array>|null $fallbackMedia Stored media used + * when the request omits the `media` key entirely — lets API/MCP partial + * updates (which don't resubmit media) validate a content_type against the + * post's already-stored media. + */ + public function __construct(private ?array $fallbackMedia = null) {} + /** * @param array $data */ @@ -28,6 +38,34 @@ public function setData(array $data): static return $this; } + /** + * Validate every enabled platform's stored content_type against the post's + * stored media. Used by publish flows that don't resubmit media (e.g. the + * MCP publish tool) — the media-side mirror of + * PostPlatformMetaRules::assertStoredPostPublishable(). + * + * @throws ValidationException + */ + 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; + }, + ); + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + /** * @param Closure(string, ?string=): PotentiallyTranslatedString $fail */ @@ -38,7 +76,11 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $media = (array) data_get($this->data, 'media', []); + // Use the request's media when present; otherwise fall back to the + // post's stored media so partial publish/schedule updates still validate. + $media = array_key_exists('media', $this->data) + ? (array) data_get($this->data, 'media', []) + : (array) ($this->fallbackMedia ?? []); $count = count($media); if ($contentType->requiresMedia() && $count === 0) { diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index b6a12c97..e7fb6299 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -68,6 +68,135 @@ expect(PostPlatform::where('social_account_id', $linkedin->id)->sole()->meta['document_title'])->toBe('Q2 Report'); }); +it('publishes a LinkedIn document post that has a PDF', function () { + Queue::fake(); + + $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, + 'content' => 'Our latest 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', + ]], + ]); + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInDocument->value]], + ]) + ->assertOk(); + + Queue::assertPushed(PublishPost::class); +}); + +it('rejects publishing a LinkedIn document post whose media is an image, not a PDF', 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, + 'media' => [[ + 'id' => 'img-1', 'path' => 'medias/slide.jpg', 'url' => 'https://example.com/slide.jpg', + 'type' => 'image', 'mime_type' => 'image/jpeg', 'original_filename' => 'slide.jpg', + ]], + ]); + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInDocument->value]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + +it('rejects publishing a regular LinkedIn post that has a PDF attached', 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, + '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, + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInPost->value]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + +it('publishes a valid LinkedIn document without resubmitting content_type', function () { + Queue::fake(); + + $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, + 'content' => 'Our 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', + ]], + ]); + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id]], + ]) + ->assertOk(); + + Queue::assertPushed(PublishPost::class); +}); + +it('rejects publishing a misconfigured post even when content_type is not resubmitted', 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, + 'media' => [[ + 'id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf', + 'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf', + ]], + ]); + // Stored as a regular post but carrying a PDF — the client publishes without resubmitting content_type. + $platform = PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true, + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['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]); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 329a0cd2..c9f264c0 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -8,6 +8,7 @@ use App\Enums\UserWorkspace\Role; use App\Jobs\PublishPost; use App\Mcp\Servers\TryPostServer; +use App\Mcp\Tools\Post\AttachMediaFromUploadTool; use App\Mcp\Tools\Post\CreatePostTool; use App\Mcp\Tools\Post\PublishPostTool; use App\Mcp\Tools\Post\UpdatePostTool; @@ -17,6 +18,7 @@ use App\Models\User; use App\Models\Workspace; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Str; beforeEach(function () { $this->user = User::factory()->create(); @@ -175,6 +177,95 @@ 'pinterest' => ['pinterest', 'board_id', 'posts.form.pinterest.board_required'], ]); +test('attach media from upload accepts a PDF for a LinkedIn post', 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, + ]); + PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + ]); + + $uploadToken = (string) Str::uuid(); + $this->workspace->media()->create([ + 'group_id' => (string) Str::uuid(), + 'collection' => 'assets', + 'type' => 'document', + 'path' => 'medias/deck.pdf', + 'original_filename' => 'deck.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1000, + 'order' => 0, + 'upload_token' => $uploadToken, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUploadTool::class, [ + 'post_id' => $post->id, + 'upload_token' => $uploadToken, + ]); + + $response->assertOk(); + + $media = $post->fresh()->media; + expect($media)->toHaveCount(1) + ->and($media[0]['type'])->toBe('document') + ->and($media[0]['mime_type'])->toBe('application/pdf'); +}); + +test('publish post succeeds for a LinkedIn document that has a PDF', function () { + Queue::fake(); + + $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', + ]], + ]); + PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertOk(); + Queue::assertPushed(PublishPost::class); +}); + +test('publish post rejects a LinkedIn document whose media is an image, not a PDF', 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' => 'img-1', 'path' => 'medias/slide.jpg', 'url' => 'https://example.com/slide.jpg', + 'type' => 'image', 'mime_type' => 'image/jpeg', 'original_filename' => 'slide.jpg', + ]], + ]); + PostPlatform::factory()->create([ + 'post_id' => $post->id, 'social_account_id' => $linkedin->id, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors(['Document does not support images.']); +}); + test('publish post succeeds for a Discord platform with a channel', function () { Queue::fake(); diff --git a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php index cc4cc75d..a557d163 100644 --- a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php +++ b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php @@ -169,6 +169,40 @@ function runMediaRule(string $contentType, array $media): array expect(runMediaRule(ContentType::LinkedInCarousel->value, $media)[0])->toContain('does not support PDF documents'); }); +test('falls back to stored media when the request omits the media key', function () { + $errors = []; + (new ContentTypeCompatibleWithMedia([['type' => 'document', 'mime_type' => 'application/pdf']])) + ->setData([]) // no 'media' key in the request -> use the fallback + ->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$errors): void { + $errors[] = $message; + }); + + expect($errors)->toBe([]); + + $imageErrors = []; + (new ContentTypeCompatibleWithMedia([['type' => 'image', 'mime_type' => 'image/jpeg']])) + ->setData([]) + ->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$imageErrors): void { + $imageErrors[] = $message; + }); + + expect($imageErrors)->toHaveCount(1); + expect($imageErrors[0])->toContain('does not support images'); +}); + +test('request media takes precedence over the stored fallback', function () { + // Fallback is a PDF, but the request explicitly carries an image instead. + $errors = []; + (new ContentTypeCompatibleWithMedia([['type' => 'document', 'mime_type' => 'application/pdf']])) + ->setData(['media' => [['type' => 'image', 'mime_type' => 'image/jpeg']]]) + ->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$errors): void { + $errors[] = $message; + }); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain('does not support images'); +}); + test('does nothing for invalid content type values', function () { expect(runMediaRule('not_a_real_content_type', []))->toBe([]); });