From c339f8cf2863c04da8cdfb32f3552e30af97c17f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 24 Jun 2026 17:36:39 -0300 Subject: [PATCH] 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 --- .../Requests/Api/Post/UpdatePostRequest.php | 28 ++----- app/Mcp/Tools/Post/UpdatePostTool.php | 20 ++++- app/Rules/ContentTypeCompatibleWithMedia.php | 73 ++++++++++++++++--- lang/en/posts.php | 2 + lang/es/posts.php | 2 + lang/pt-BR/posts.php | 2 + resources/js/composables/usePostCompliance.ts | 8 ++ tests/Feature/Api/PostApiPlatformMetaTest.php | 35 +++++++++ .../Mcp/AttachMediaFromUrlToolTest.php | 58 +++++++++++++++ .../Feature/Mcp/PostPlatformMetaToolTest.php | 28 +++++++ .../Social/LinkedInPagePublisherTest.php | 36 +++++++++ .../Services/Social/LinkedInPublisherTest.php | 47 ++++++++++++ tests/Unit/Enums/PlatformTest.php | 4 +- 13 files changed, 304 insertions(+), 39 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 47a7ada1..7f226fb8 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -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); } } diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 0e7832ce..18dea6c5 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -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); diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 99c0849a..21100281 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -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|null $requestPlatforms + * @return array + */ + 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 $entries + * @param array $media + * @return array + */ + 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 */ diff --git a/lang/en/posts.php b/lang/en/posts.php index 29b1c5fb..d0992a12 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -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.', ], diff --git a/lang/es/posts.php b/lang/es/posts.php index 4a8d3096..06c05ef5 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -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.', ], diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 71630476..e936859c 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -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.', ], diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index 681b8e11..1390a817 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -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) { diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index e7fb6299..dd16ca79 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -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]); diff --git a/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php b/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php index 6b2aa69a..46a36e7e 100644 --- a/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php +++ b/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php @@ -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<>endobj\ntrailer<>\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<>endobj\ntrailer<>\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]); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index c9f264c0..154f38f7 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -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(); diff --git a/tests/Feature/Services/Social/LinkedInPagePublisherTest.php b/tests/Feature/Services/Social/LinkedInPagePublisherTest.php index 1f119ba2..9f7313f5 100644 --- a/tests/Feature/Services/Social/LinkedInPagePublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPagePublisherTest.php @@ -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')); +}); diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index 87d87920..a1ee47e7 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -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')); +}); diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index d28c3ed4..65915963 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -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);