feat(linkedin): enforce PDF/media compatibility on API and MCP

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
This commit is contained in:
Paulo Castellano 2026-06-24 17:08:48 -03:00
parent 7388313f5c
commit 14e44538a7
8 changed files with 358 additions and 5 deletions

View file

@ -52,9 +52,6 @@ public function isDocument(): bool
return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf';
}
/**
* @param array<string, mixed> $data
*/
/**
* @param array<string, mixed> $data
*/

View file

@ -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<int|string, Platform>
*/

View file

@ -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');

View file

@ -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(),
]);

View file

@ -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<int, array<string, mixed>>|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<string, mixed> $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) {

View file

@ -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]);

View file

@ -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();

View file

@ -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([]);
});