diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index 2565169b..c4465256 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -251,8 +251,8 @@ private function extractContent(array $structured, GeneratorFormat $format, Cont * * Carousel-capable content types: * - instagram_feed (Instagram feed carousel = multi-image feed post) - * - linkedin_carousel (LinkedIn personal carousel PDF) - * - linkedin_page_carousel (LinkedIn page carousel PDF) + * - linkedin_post (LinkedIn multi-image post — 2+ images) + * - linkedin_page_post (LinkedIn page multi-image post) * - pinterest_carousel (Pinterest carousel pin) * - tiktok_photo (TikTok photo carousel) * diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 985fa647..f5230aee 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -13,15 +13,12 @@ enum ContentType: string case InstagramReel = 'instagram_reel'; case InstagramStory = 'instagram_story'; - // LinkedIn + // LinkedIn — one type per account kind; the publish format (single image, + // multi-image, video, or PDF document) is inferred from the attached media. case LinkedInPost = 'linkedin_post'; - case LinkedInCarousel = 'linkedin_carousel'; - case LinkedInDocument = 'linkedin_document'; // LinkedIn Page case LinkedInPagePost = 'linkedin_page_post'; - case LinkedInPageCarousel = 'linkedin_page_carousel'; - case LinkedInPageDocument = 'linkedin_page_document'; // Facebook case FacebookPost = 'facebook_post'; @@ -71,8 +68,6 @@ public function label(): string self::InstagramReel => 'Reel', self::InstagramStory => 'Story', self::LinkedInPost, self::LinkedInPagePost => 'Post', - self::LinkedInCarousel, self::LinkedInPageCarousel => 'Carousel', - self::LinkedInDocument, self::LinkedInPageDocument => 'Document', self::FacebookPost => 'Post', self::FacebookReel => 'Reel', self::FacebookStory => 'Story', @@ -100,8 +95,8 @@ public function platform(): SocialPlatform { return match ($this) { self::InstagramFeed, self::InstagramReel, self::InstagramStory => SocialPlatform::Instagram, - self::LinkedInPost, self::LinkedInCarousel, self::LinkedInDocument => SocialPlatform::LinkedIn, - self::LinkedInPagePost, self::LinkedInPageCarousel, self::LinkedInPageDocument => SocialPlatform::LinkedInPage, + self::LinkedInPost => SocialPlatform::LinkedIn, + self::LinkedInPagePost => SocialPlatform::LinkedInPage, self::FacebookPost, self::FacebookReel, self::FacebookStory => SocialPlatform::Facebook, self::TikTokVideo, self::TikTokPhoto => SocialPlatform::TikTok, self::YouTubeShort => SocialPlatform::YouTube, @@ -167,9 +162,7 @@ public function maxMediaCount(): int return match ($this) { self::InstagramFeed => 10, self::InstagramReel, self::InstagramStory => 1, - self::LinkedInPost, self::LinkedInPagePost => 1, - self::LinkedInCarousel, self::LinkedInPageCarousel => 20, - self::LinkedInDocument, self::LinkedInPageDocument => 1, + self::LinkedInPost, self::LinkedInPagePost => 20, self::FacebookPost => 10, self::FacebookReel, self::FacebookStory => 1, self::TikTokVideo => 1, @@ -191,8 +184,6 @@ public function supportsVideo(): bool return match ($this) { self::InstagramFeed, self::InstagramReel, self::InstagramStory => true, self::LinkedInPost, self::LinkedInPagePost => true, - self::LinkedInCarousel, self::LinkedInPageCarousel => false, - self::LinkedInDocument, self::LinkedInPageDocument => false, self::FacebookPost, self::FacebookReel, self::FacebookStory => true, self::TikTokVideo => true, self::TikTokPhoto => false, @@ -217,20 +208,19 @@ public function supportsImage(): bool self::TikTokPhoto => true, self::YouTubeShort => false, self::PinterestVideoPin => false, - self::LinkedInDocument, self::LinkedInPageDocument => false, default => true, }; } /** - * Whether this content type carries a document (PDF) attachment instead of - * images/videos — currently the LinkedIn swipeable document ("carousel"). - * A document post is PDF-only: it never combines with images or videos. + * Whether this content type can carry a PDF document (the swipeable LinkedIn + * document/carousel). LinkedIn infers the document format from a PDF being + * attached; the document is always the only attachment (see the media rule). */ public function supportsDocument(): bool { return match ($this) { - self::LinkedInDocument, self::LinkedInPageDocument => true, + self::LinkedInPost, self::LinkedInPagePost => true, default => false, }; } @@ -245,6 +235,8 @@ public function supportsMixedMedia(): bool { return match ($this) { self::BlueskyPost => false, + // LinkedIn publishes images XOR one video XOR one document — never mixed. + self::LinkedInPost, self::LinkedInPagePost => false, default => true, }; } diff --git a/app/Enums/SocialAccount/LinkedInIdentityType.php b/app/Enums/SocialAccount/LinkedInIdentityType.php new file mode 100644 index 00000000..db15de5f --- /dev/null +++ b/app/Enums/SocialAccount/LinkedInIdentityType.php @@ -0,0 +1,16 @@ + 1, + self::LinkedIn, self::LinkedInPage => 20, self::X => 4, self::TikTok => 0, self::YouTube => 0, @@ -287,6 +287,22 @@ public function isEnabled(): bool return config("trypost.platforms.{$this->value}.enabled", true); } + /** + * Whether this platform gets its own "Connect" card in the accounts grid. + * LinkedIn company pages are reached through the unified LinkedIn connect + * flow's identity picker, never a standalone card. The single LinkedIn card + * stands for the whole network, so it shows whenever the profile OR the + * company-page capability is enabled (self-hosters may run with only one). + */ + public function isConnectable(): bool + { + return match ($this) { + self::LinkedInPage => false, + self::LinkedIn => self::LinkedIn->isEnabled() || self::LinkedInPage->isEnabled(), + default => $this->isEnabled(), + }; + } + /** * Static, platform-specific data exposed to the frontend (e.g. TikTok privacy options, * compliance URLs). Returns an empty array for platforms with no extra config. diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 05fb0b44..2fcfa6cc 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -4,41 +4,64 @@ namespace App\Http\Controllers\Auth; +use App\Enums\SocialAccount\LinkedInIdentityType; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Models\Workspace; -use App\Services\Social\LinkedInTokenSynchronizer; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rules\Enum; use Illuminate\View\View; +use Inertia\Inertia; +use Inertia\Response as InertiaResponse; use Laravel\Socialite\Facades\Socialite; use Symfony\Component\HttpFoundation\Response; class LinkedInController extends SocialController { - protected string $driver = 'linkedin'; + protected string $driver = 'linkedin-openid'; protected SocialPlatform $platform = SocialPlatform::LinkedIn; - public function connect(Request $request): Response|RedirectResponse + /** + * The LinkedIn card stands for both the personal profile and company-page + * capabilities, each independently toggleable so self-hosters can run with + * just one. The connect flow is available while either is enabled. + */ + protected function ensurePlatformEnabled(): void + { + if (! $this->personEnabled() && ! $this->organizationEnabled()) { + abort(Response::HTTP_FORBIDDEN, 'This platform is currently unavailable.'); + } + } + + public function connect(Request $request): Response|View { $this->ensurePlatformEnabled(); $workspace = $request->user()->currentWorkspace; if (! $workspace) { - return redirect()->route('app.workspaces.create'); + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); } $this->authorize('manageAccounts', $workspace); - return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.linkedin.scopes')); + session(['social_connect_workspace' => $workspace->id]); + + return Inertia::location( + Socialite::driver($this->driver) + ->scopes($this->connectScopes()) + ->redirect() + ->getTargetUrl() + ); } - public function callback(Request $request): View + public function callback(Request $request): View|RedirectResponse { $workspaceId = session('social_connect_workspace'); @@ -55,37 +78,24 @@ public function callback(Request $request): View try { $socialUser = Socialite::driver($this->driver)->user(); - // Fetch vanityName from LinkedIn API (not available via OpenID) - $username = $this->fetchVanityName($socialUser->token); - - $avatarPath = uploadFromUrl($socialUser->getAvatar()); - - $account = $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => $socialUser->getId(), - ], - [ - 'username' => $username, - 'display_name' => $socialUser->getName(), - 'avatar_url' => $avatarPath, - 'access_token' => $socialUser->token, + session([ + 'linkedin_pending' => [ + 'workspace_id' => $workspace->id, + 'token' => $socialUser->token, 'refresh_token' => $socialUser->refreshToken, - 'token_expires_at' => $socialUser->expiresIn ? now()->addSeconds($socialUser->expiresIn) : null, - // LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here. - 'scopes' => explode(',', implode(',', $socialUser->approvedScopes)), - 'status' => Status::Connected, - 'error_message' => null, - 'disconnected_at' => null, + 'expires_in' => $socialUser->expiresIn, + 'approved_scopes' => $socialUser->approvedScopes ?? [], + 'person' => [ + 'id' => $socialUser->getId(), + 'name' => $socialUser->getName(), + 'avatar' => $socialUser->getAvatar(), + 'vanity_name' => $this->personEnabled() ? $this->fetchVanityName($socialUser->token) : null, + ], + 'organizations' => $this->organizationEnabled() ? $this->fetchOrganizations($socialUser->token) : [], ], - ); + ]); - // Sync tokens to LinkedIn Page if it exists - app(LinkedInTokenSynchronizer::class)->syncTokens($account); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException $e) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + return redirect()->route('app.social.linkedin.select-identity'); } catch (\Exception $e) { Log::error('LinkedIn OAuth Error', [ 'error' => $e->getMessage(), @@ -95,6 +105,209 @@ public function callback(Request $request): View } } + public function selectIdentity(Request $request): InertiaResponse|View + { + $pending = session('linkedin_pending'); + + if (! $pending) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($pending['workspace_id']); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + return Inertia::render('accounts/LinkedInSelect', [ + 'person' => $this->personEnabled() ? $pending['person'] : null, + 'organizations' => $pending['organizations'], + ]); + } + + public function select(Request $request): View + { + $validator = Validator::make($request->all(), [ + 'type' => ['required', new Enum(LinkedInIdentityType::class)], + 'organization_id' => 'required_if:type,organization', + ]); + + if ($validator->fails()) { + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + + $validated = $validator->validated(); + + $pending = session('linkedin_pending'); + + if (! $pending) { + return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); + } + + $workspace = Workspace::find($pending['workspace_id']); + + if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { + return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); + } + + $type = LinkedInIdentityType::from(data_get($validated, 'type')); + + if (($type === LinkedInIdentityType::Organization && ! $this->organizationEnabled()) + || ($type === LinkedInIdentityType::Person && ! $this->personEnabled())) { + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + + try { + if ($type === LinkedInIdentityType::Organization) { + $organization = $this->resolveAdministeredOrganization($pending, data_get($validated, 'organization_id')); + + if (! $organization) { + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + + $this->connectOrganization($workspace, $pending, $organization); + } else { + $this->connectPerson($workspace, $pending); + } + + session()->forget('linkedin_pending'); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } catch (NetworkAlreadyConnectedException) { + return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); + } catch (\Exception $e) { + Log::error('LinkedIn selection error', [ + 'error' => $e->getMessage(), + ]); + + return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); + } + } + + /** + * The user's personal LinkedIn profile becomes a `linkedin` account. + */ + private function connectPerson(Workspace $workspace, array $pending): void + { + $person = $pending['person']; + + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => SocialPlatform::LinkedIn->value, + 'platform_user_id' => data_get($person, 'id'), + ], + [ + 'username' => data_get($person, 'vanity_name'), + 'display_name' => data_get($person, 'name'), + 'avatar_url' => uploadFromUrl(data_get($person, 'avatar')), + 'access_token' => $pending['token'], + 'refresh_token' => $pending['refresh_token'], + 'token_expires_at' => $pending['expires_in'] ? now()->addSeconds($pending['expires_in']) : null, + 'scopes' => $this->normalizeScopes($pending['approved_scopes'] ?? []), + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + ], + ); + } + + /** + * Match the chosen organization id against the admin-verified list captured at + * callback, so a tampered POST cannot connect a company the member does not + * administer. + * + * @param array $pending + * @return array|null + */ + private function resolveAdministeredOrganization(array $pending, mixed $organizationId): ?array + { + return collect(data_get($pending, 'organizations', [])) + ->first(fn ($organization) => (string) data_get($organization, 'id') === (string) $organizationId); + } + + /** + * A company the user administers becomes a `linkedin-page` account, with the + * acting member recorded in meta so the page publisher can post on its behalf. + * The organization data comes from the admin-verified session list, never the + * request body. + * + * @param array $pending + * @param array $organization + */ + private function connectOrganization(Workspace $workspace, array $pending, array $organization): void + { + $organizationId = data_get($organization, 'id'); + + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => SocialPlatform::LinkedInPage->value, + 'platform_user_id' => $organizationId, + ], + [ + 'username' => data_get($organization, 'vanity_name'), + 'display_name' => data_get($organization, 'name'), + 'avatar_url' => uploadFromUrl(data_get($organization, 'logo')), + 'access_token' => $pending['token'], + 'refresh_token' => $pending['refresh_token'], + 'token_expires_at' => $pending['expires_in'] ? now()->addSeconds($pending['expires_in']) : null, + 'scopes' => $this->normalizeScopes($pending['approved_scopes'] ?? []), + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'organization_id' => $organizationId, + 'admin_user_id' => data_get($pending, 'person.id'), + 'admin_name' => data_get($pending, 'person.name'), + ], + ], + ); + } + + /** + * Union of the scopes for the enabled capabilities, so one consent screen + * grants exactly what the workspace can use — member posting, company-page + * administration, or both — and the user picks the identity afterwards. + * + * @return array + */ + private function connectScopes(): array + { + $scopes = []; + + if ($this->personEnabled()) { + $scopes = array_merge($scopes, config('trypost.platforms.linkedin.scopes')); + } + + if ($this->organizationEnabled()) { + $scopes = array_merge($scopes, config('trypost.platforms.linkedin-page.scopes')); + } + + return array_values(array_unique($scopes)); + } + + private function personEnabled(): bool + { + return SocialPlatform::LinkedIn->isEnabled(); + } + + private function organizationEnabled(): bool + { + return SocialPlatform::LinkedInPage->isEnabled(); + } + + /** + * LinkedIn returns approved scopes comma-joined, but Socialite splits OAuth + * scopes on space — so the whole CSV lands as a single array element. Re-split + * on commas to store individual scope tokens. + * + * @param array $approvedScopes + * @return array + */ + private function normalizeScopes(array $approvedScopes): array + { + return array_values(array_filter(explode(',', implode(',', $approvedScopes)))); + } + private function fetchVanityName(string $accessToken): ?string { try { @@ -115,4 +328,45 @@ private function fetchVanityName(string $accessToken): ?string return null; } + + /** + * Organizations the authenticated member administers, used to offer company + * pages as a posting identity alongside their personal profile. + * + * @return array + */ + private function fetchOrganizations(string $accessToken): array + { + $response = Http::withToken($accessToken) + ->get(config('trypost.platforms.linkedin.api').'/v2/organizationAcls', [ + 'q' => 'roleAssignee', + 'role' => 'ADMINISTRATOR', + 'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(original~:playableStreams))))', + ]); + + if ($response->failed()) { + Log::error('LinkedIn Organizations fetch error', [ + 'error' => $response->body(), + ]); + + return []; + } + + $organizations = []; + + foreach (data_get($response->json(), 'elements', []) as $element) { + $org = data_get($element, 'organization~'); + + if ($org) { + $organizations[] = [ + 'id' => data_get($org, 'id'), + 'name' => data_get($org, 'localizedName', 'Unknown'), + 'vanity_name' => data_get($org, 'vanityName'), + 'logo' => data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier'), + ]; + } + } + + return $organizations; + } } diff --git a/app/Http/Controllers/Auth/LinkedInPageController.php b/app/Http/Controllers/Auth/LinkedInPageController.php deleted file mode 100644 index 35ec1c59..00000000 --- a/app/Http/Controllers/Auth/LinkedInPageController.php +++ /dev/null @@ -1,273 +0,0 @@ -ensurePlatformEnabled(); - - $workspace = $request->user()->currentWorkspace; - - if (! $workspace) { - return redirect()->route('app.workspaces.create'); - } - - $this->authorize('manageAccounts', $workspace); - - session([ - 'social_connect_workspace' => $workspace->id, - 'linkedin_page_reconnect_id' => null, - ]); - - return Inertia::location( - Socialite::driver($this->driver) - ->scopes(config('trypost.platforms.linkedin-page.scopes')) - ->with([ - 'redirect_uri' => config('services.linkedin-openid.redirect_page'), - ]) - ->redirect() - ->getTargetUrl() - ); - } - - public function callback(Request $request): View|RedirectResponse - { - $workspaceId = session('social_connect_workspace'); - - if (! $workspaceId) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($workspaceId); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } - - try { - $socialUser = Socialite::driver($this->driver) - ->scopes(config('trypost.platforms.linkedin-page.scopes')) - ->with([ - 'redirect_uri' => config('services.linkedin-openid.redirect_page'), - ]) - ->user(); - - // Fetch organizations the user is admin of - $organizations = $this->fetchOrganizations($socialUser->token); - - if (empty($organizations)) { - return $this->popupCallback(false, __('accounts.popup_callback.not_linkedin_admin'), $this->platform->value); - } - - // Store data in session and redirect to selection page - session([ - 'linkedin_page_pending' => [ - 'workspace_id' => $workspace->id, - 'user_id' => $socialUser->getId(), - 'name' => $socialUser->getName(), - 'avatar' => $socialUser->getAvatar(), - 'token' => $socialUser->token, - 'refresh_token' => $socialUser->refreshToken, - 'expires_in' => $socialUser->expiresIn, - 'approved_scopes' => $socialUser->approvedScopes ?? [], - 'organizations' => $organizations, - 'reconnect_id' => session('linkedin_page_reconnect_id'), - ], - ]); - - return redirect()->route('app.social.linkedin-page.select-page'); - } catch (\Exception $e) { - Log::error('LinkedIn Page OAuth Error', [ - 'error' => $e->getMessage(), - ]); - - return $this->popupCallback(false, __('accounts.popup_callback.error_connecting'), $this->platform->value); - } - } - - public function selectPage(Request $request): Response|RedirectResponse - { - $pendingData = session('linkedin_page_pending'); - - if (! $pendingData) { - session()->flash('flash.banner', __('accounts.flash.session_expired')); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('app.accounts'); - } - - $workspace = Workspace::find($pendingData['workspace_id']); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - session()->flash('flash.banner', __('accounts.flash.workspace_not_found')); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('app.accounts'); - } - - return Inertia::render('accounts/LinkedInPageSelect', [ - 'workspace' => $workspace, - 'organizations' => $pendingData['organizations'], - ]); - } - - public function select(Request $request): View - { - $request->validate([ - 'organization_id' => 'required', - 'organization_name' => 'required|string', - 'organization_vanity_name' => 'nullable|string', - 'organization_logo' => 'nullable|string', - ]); - - $pendingData = session('linkedin_page_pending'); - - if (! $pendingData) { - return $this->popupCallback(false, __('accounts.popup_callback.session_expired'), $this->platform->value); - } - - $workspace = Workspace::find($pendingData['workspace_id']); - - if (! $workspace || ! $request->user()->can('manageAccounts', $workspace)) { - return $this->popupCallback(false, __('accounts.popup_callback.workspace_not_found'), $this->platform->value); - } - - try { - $avatarPath = uploadFromUrl($request->organization_logo); - $reconnectId = $pendingData['reconnect_id'] ?? null; - - if ($reconnectId) { - // Reconnect existing account - $existingAccount = $workspace->socialAccounts()->find($reconnectId); - - if ($existingAccount) { - $existingAccount->update([ - 'platform_user_id' => $request->organization_id, - 'username' => $request->organization_vanity_name, - 'display_name' => $request->organization_name, - 'avatar_url' => $avatarPath, - 'access_token' => $pendingData['token'], - 'refresh_token' => $pendingData['refresh_token'], - 'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null, - // LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here. - 'scopes' => explode(',', implode(',', $pendingData['approved_scopes'] ?? [])), - 'meta' => [ - 'organization_id' => $request->organization_id, - 'admin_user_id' => $pendingData['user_id'], - 'admin_name' => $pendingData['name'], - ], - ]); - $existingAccount->markAsConnected(); - - // Sync tokens to LinkedIn personal if it exists - app(LinkedInTokenSynchronizer::class)->syncTokens($existingAccount); - - session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']); - - return $this->popupCallback(true, __('accounts.popup_callback.reconnected'), $this->platform->value); - } - } - - $account = $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => $this->platform->value, - 'platform_user_id' => $request->organization_id, - ], - [ - 'username' => $request->organization_vanity_name, - 'display_name' => $request->organization_name, - 'avatar_url' => $avatarPath, - 'access_token' => $pendingData['token'], - 'refresh_token' => $pendingData['refresh_token'], - 'token_expires_at' => $pendingData['expires_in'] ? now()->addSeconds($pendingData['expires_in']) : null, - // LinkedIn returns scope CSV-joined but Socialite splits on space, so re-split here. - 'scopes' => explode(',', implode(',', $pendingData['approved_scopes'] ?? [])), - 'status' => Status::Connected, - 'error_message' => null, - 'disconnected_at' => null, - 'meta' => [ - 'organization_id' => $request->organization_id, - 'admin_user_id' => $pendingData['user_id'], - 'admin_name' => $pendingData['name'], - ], - ], - ); - - // Sync tokens to LinkedIn personal if it exists - app(LinkedInTokenSynchronizer::class)->syncTokens($account); - - session()->forget(['linkedin_page_pending', 'linkedin_page_reconnect_id']); - - return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); - } catch (NetworkAlreadyConnectedException) { - return $this->popupCallback(false, __('accounts.popup_callback.network_taken'), $this->platform->value); - } catch (\Exception $e) { - Log::error('LinkedIn Page selection error', [ - 'error' => $e->getMessage(), - ]); - - return $this->popupCallback(false, __('accounts.popup_callback.error_connecting_page'), $this->platform->value); - } - } - - private function fetchOrganizations(string $accessToken): array - { - $response = Http::withToken($accessToken) - ->get(config('trypost.platforms.linkedin-page.api').'/v2/organizationAcls', [ - 'q' => 'roleAssignee', - 'role' => 'ADMINISTRATOR', - 'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(original~:playableStreams))))', - ]); - - if ($response->failed()) { - Log::error('LinkedIn Organizations fetch error', [ - 'error' => $response->body(), - ]); - - return []; - } - - $data = $response->json(); - $organizations = []; - - foreach (data_get($data, 'elements', []) as $element) { - $org = data_get($element, 'organization~', null); - if ($org) { - $logoUrl = null; - $logoUrl = data_get($org, 'logoV2.original~.elements.0.identifiers.0.identifier'); - - $organizations[] = [ - 'id' => data_get($org, 'id'), - 'name' => data_get($org, 'localizedName', 'Unknown'), - 'vanity_name' => data_get($org, 'vanityName', null), - 'logo' => $logoUrl, - ]; - } - } - - return $organizations; - } -} diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index bd4a68cd..b049785d 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -43,12 +43,14 @@ public function index(Request $request): Response|RedirectResponse $this->authorize('manageAccounts', $workspace); - $platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [ - 'value' => $platform->value, - 'label' => $platform->label(), - 'color' => $platform->color(), - 'network' => $platform->network(), - ])->values(); + $platforms = collect(SocialPlatform::cases()) + ->filter(fn ($platform) => $platform->isConnectable()) + ->map(fn ($platform) => [ + 'value' => $platform->value, + 'label' => $platform->label(), + 'color' => $platform->color(), + 'network' => $platform->network(), + ])->values(); return Inertia::render('accounts/Index', [ 'workspace' => $workspace, diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 21100281..99e2ad12 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -158,6 +158,11 @@ public function validate(string $attribute, mixed $value, Closure $fail): void $fail("{$contentType->label()} does not support PDF documents."); } + // A PDF document is always published on its own (LinkedIn document post). + if ($hasDocument && $count > 1) { + $fail('A PDF document must be the only attachment.'); + } + if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) { $fail("{$contentType->label()} can't combine an image and a video in the same post."); } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 92f62433..085699b1 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -135,9 +135,6 @@ private function refreshLinkedInToken(SocialAccount $account): void ]); $account->refresh(); - - // Sync tokens between LinkedIn personal and LinkedIn Page - app(LinkedInTokenSynchronizer::class)->syncTokens($account); } private function refreshXToken(SocialAccount $account): void diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 7df6d1d4..68d7bd45 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; @@ -58,20 +57,34 @@ public function publish(PostPlatform $postPlatform): array } $organizationUrn = "urn:li:organization:{$organizationId}"; - $contentType = $postPlatform->content_type; try { - return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), - ContentType::LinkedInPageDocument => $this->publishDocument($organizationUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform), $this->account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), - default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), - }; + return $this->dispatchByMedia($organizationUrn, $content, $postPlatform); } catch (TokenExpiredException $e) { return $this->retryWithRefresh($postPlatform, $content, $e); } } + /** + * The page publish format follows the attached media — a PDF becomes a + * document post, 2+ images a multi-image post, and anything else a regular + * post. + */ + private function dispatchByMedia(string $organizationUrn, ?string $content, PostPlatform $postPlatform): array + { + $media = $postPlatform->post->mediaItems; + + if ($media->contains(fn ($item) => $item->isDocument())) { + return $this->publishDocument($organizationUrn, $content, $media, $this->resolveDocumentTitle($postPlatform), $this->account); + } + + if ($media->filter(fn ($item) => $item->isImage())->count() >= 2) { + return $this->publishCarousel($organizationUrn, $content, $media, $this->account); + } + + return $this->publishPost($organizationUrn, $content, $media, $this->account); + } + private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { @@ -86,14 +99,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, $organizationId = $this->account->meta['organization_id'] ?? null; $organizationUrn = "urn:li:organization:{$organizationId}"; - $contentType = $postPlatform->content_type; - return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), - ContentType::LinkedInPageDocument => $this->publishDocument($organizationUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform), $this->account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->post->mediaItems, $this->account), - default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), - }; + return $this->dispatchByMedia($organizationUrn, $content, $postPlatform); } catch (\Throwable $e) { Log::error('LinkedIn Page refresh failed during retry', [ 'account_id' => $this->account->id, @@ -175,10 +182,6 @@ private function publishCarousel(string $organizationUrn, ?string $content, $med } } - if (empty($carouselItems)) { - throw new \Exception('No valid images for LinkedIn Page carousel'); - } - $payload = [ 'author' => $organizationUrn, 'commentary' => $content ?? '', diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 1e120948..d60b81e2 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -4,7 +4,6 @@ namespace App\Services\Social; -use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; @@ -52,20 +51,34 @@ public function publish(PostPlatform $postPlatform): array $this->accessToken = $this->account->access_token; $personUrn = "urn:li:person:{$this->account->platform_user_id}"; - $contentType = $postPlatform->content_type; try { - return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems), - ContentType::LinkedInDocument => $this->publishDocument($personUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform)), - ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems), - default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), - }; + return $this->dispatchByMedia($personUrn, $content, $postPlatform); } catch (TokenExpiredException $e) { return $this->retryWithRefresh($postPlatform, $content, $e); } } + /** + * LinkedIn's publish format follows the attached media — a PDF becomes a + * document post, 2+ images a multi-image post, and anything else (a single + * image/video or text only) a regular post. + */ + private function dispatchByMedia(string $personUrn, ?string $content, PostPlatform $postPlatform): array + { + $media = $postPlatform->post->mediaItems; + + if ($media->contains(fn ($item) => $item->isDocument())) { + return $this->publishDocument($personUrn, $content, $media, $this->resolveDocumentTitle($postPlatform)); + } + + if ($media->filter(fn ($item) => $item->isImage())->count() >= 2) { + return $this->publishCarousel($personUrn, $content, $media); + } + + return $this->publishPost($personUrn, $content, $media); + } + private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { @@ -79,14 +92,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, $this->accessToken = $this->account->access_token; $personUrn = "urn:li:person:{$this->account->platform_user_id}"; - $contentType = $postPlatform->content_type; - return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->post->mediaItems), - ContentType::LinkedInDocument => $this->publishDocument($personUrn, $content, $postPlatform->post->mediaItems, $this->resolveDocumentTitle($postPlatform)), - ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->post->mediaItems), - default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), - }; + return $this->dispatchByMedia($personUrn, $content, $postPlatform); } catch (\Throwable $e) { Log::error('LinkedIn refresh failed during retry', [ 'account_id' => $this->account->id, @@ -162,10 +169,6 @@ private function publishCarousel(string $personUrn, ?string $content, $mediaColl } } - if (empty($carouselItems)) { - throw new \Exception('No valid images for LinkedIn carousel'); - } - $payload = [ 'author' => $personUrn, 'commentary' => $content ?? '', diff --git a/app/Services/Social/LinkedInTokenSynchronizer.php b/app/Services/Social/LinkedInTokenSynchronizer.php deleted file mode 100644 index 11895412..00000000 --- a/app/Services/Social/LinkedInTokenSynchronizer.php +++ /dev/null @@ -1,86 +0,0 @@ -workspace; - $linkedUserId = $this->getLinkedInUserId($sourceAccount); - - if (! $linkedUserId) { - return; - } - - $targets = $this->findLinkedAccounts($workspace, $sourceAccount, $linkedUserId); - - foreach ($targets as $target) { - $target->update([ - 'access_token' => $sourceAccount->access_token, - 'refresh_token' => $sourceAccount->refresh_token, - 'token_expires_at' => $sourceAccount->token_expires_at, - ]); - - if ($target->isDisconnected()) { - $target->markAsConnected(); - } - } - } - - /** - * Get the LinkedIn user ID from an account. - * For personal accounts, it's the platform_user_id. - * For page accounts, it's stored in meta.admin_user_id. - */ - private function getLinkedInUserId(SocialAccount $account): ?string - { - if ($account->platform === Platform::LinkedIn) { - return $account->platform_user_id; - } - - if ($account->platform === Platform::LinkedInPage) { - return $account->meta['admin_user_id'] ?? null; - } - - return null; - } - - /** - * Find every linked LinkedIn account (personal <-> page) in the same workspace. - * - * @return Collection - */ - private function findLinkedAccounts(Workspace $workspace, SocialAccount $sourceAccount, string $linkedUserId): Collection - { - if ($sourceAccount->platform === Platform::LinkedIn) { - // Source is personal: every page admin'd by this LinkedIn user. - return $workspace->socialAccounts() - ->where('platform', Platform::LinkedInPage->value) - ->whereJsonContains('meta->admin_user_id', $linkedUserId) - ->get(); - } - - if ($sourceAccount->platform === Platform::LinkedInPage) { - // Source is page: the personal LinkedIn account belonging to its admin. - return $workspace->socialAccounts() - ->where('platform', Platform::LinkedIn->value) - ->where('platform_user_id', $linkedUserId) - ->get(); - } - - return new Collection; - } -} diff --git a/config/services.php b/config/services.php index e72632d6..4b5d5690 100644 --- a/config/services.php +++ b/config/services.php @@ -47,7 +47,6 @@ 'client_id' => env('LINKEDIN_CLIENT_ID'), 'client_secret' => env('LINKEDIN_CLIENT_SECRET'), 'redirect' => env('LINKEDIN_CLIENT_REDIRECT'), - 'redirect_page' => env('LINKEDIN_PAGE_CLIENT_REDIRECT'), ], 'x' => [ diff --git a/database/migrations/2026_06_24_220651_collapse_linkedin_content_types.php b/database/migrations/2026_06_24_220651_collapse_linkedin_content_types.php new file mode 100644 index 00000000..4fda2962 --- /dev/null +++ b/database/migrations/2026_06_24_220651_collapse_linkedin_content_types.php @@ -0,0 +1,31 @@ +whereIn('content_type', ['linkedin_carousel', 'linkedin_document']) + ->update(['content_type' => 'linkedin_post']); + + DB::table('post_platforms') + ->whereIn('content_type', ['linkedin_page_carousel', 'linkedin_page_document']) + ->update(['content_type' => 'linkedin_page_post']); + } + + public function down(): void + { + // Irreversible: the per-format variants were merged into the single post + // type and the original distinction is no longer recoverable. + } +}; diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 8359c84c..880c0082 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -15,7 +15,7 @@ 'disconnect' => 'Disconnect', 'descriptions' => [ - 'linkedin' => 'Connect your LinkedIn personal profile', + 'linkedin' => 'Connect your LinkedIn profile or company page', 'linkedin-page' => 'Connect a LinkedIn company page', 'x' => 'Connect your X (Twitter) account', 'tiktok' => 'Connect your TikTok account', @@ -98,6 +98,12 @@ 'no_pages' => 'No pages found', 'no_pages_description' => 'You are not an administrator of any LinkedIn page.', 'page_label' => 'LinkedIn Page', + 'select_title' => 'Where do you want to post?', + 'select_subtitle' => 'Post as yourself or choose a company page you manage.', + 'person_tag' => 'Person', + 'organization_tag' => 'Organization', + 'view' => 'View', + 'choose' => 'Choose', ], 'flash' => [ diff --git a/lang/en/posts.php b/lang/en/posts.php index d0992a12..d2cd6f8f 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -139,12 +139,8 @@ 'settings' => 'LinkedIn Settings', 'settings_page' => 'LinkedIn Page Settings', 'posting_to' => 'Posting to', - 'variant_label' => 'Post type', - 'variant' => [ - 'post' => 'Post', - 'carousel' => 'Carousel', - 'document' => 'Document', - ], + 'document_title' => 'Document title', + 'document_title_placeholder' => 'Shown on your PDF document post', ], 'pinterest' => [ 'settings' => 'Pinterest Settings', @@ -190,6 +186,8 @@ 'no_video_allowed' => 'This post type does not accept videos.', 'no_image_allowed' => 'This post type accepts only videos.', 'no_document_allowed' => 'This post type does not accept PDF documents.', + 'no_mixed_media' => "Images and a video can't be combined in the same post.", + 'document_not_alone' => 'A PDF must be the only attachment.', 'gif_not_allowed' => 'This platform does not accept GIF. Remove the GIF or choose a different network.', 'image_too_large' => 'Image exceeds the :max limit for this post type (yours is :current).', 'video_too_large' => 'Video exceeds the :max limit for this post type (yours is :current).', @@ -348,6 +346,7 @@ '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.', + 'document_not_alone' => 'A PDF must be the only attachment.', '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.', @@ -463,27 +462,11 @@ ], 'linkedin_post' => [ 'label' => 'Post', - 'description' => 'Standard post with text and media', - ], - 'linkedin_carousel' => [ - 'label' => 'Carousel', - 'description' => 'Swipeable images', + 'description' => 'Standard post — single image, multi-image, video, or PDF', ], 'linkedin_page_post' => [ 'label' => 'Post', - 'description' => 'Standard post with text and media', - ], - 'linkedin_page_carousel' => [ - 'label' => 'Carousel', - 'description' => 'Swipeable images', - ], - 'linkedin_document' => [ - 'label' => 'Document', - 'description' => 'Swipeable PDF carousel', - ], - 'linkedin_page_document' => [ - 'label' => 'Document', - 'description' => 'Swipeable PDF carousel', + 'description' => 'Standard post — single image, multi-image, video, or PDF', ], 'facebook_post' => [ 'label' => 'Post', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 3df54c2b..71f88021 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -15,7 +15,7 @@ 'disconnect' => 'Desconectar', 'descriptions' => [ - 'linkedin' => 'Conecta tu perfil personal de LinkedIn', + 'linkedin' => 'Conecta tu perfil de LinkedIn o página de empresa', 'linkedin-page' => 'Conecta una página de empresa de LinkedIn', 'x' => 'Conecta tu cuenta de X (Twitter)', 'tiktok' => 'Conecta tu cuenta de TikTok', @@ -98,6 +98,12 @@ 'no_pages' => 'No se encontraron páginas', 'no_pages_description' => 'No eres administrador de ninguna página de LinkedIn.', 'page_label' => 'Página de LinkedIn', + 'select_title' => '¿Dónde quieres publicar?', + 'select_subtitle' => 'Publica como tú mismo o elige una página de empresa que administres.', + 'person_tag' => 'Persona', + 'organization_tag' => 'Empresa', + 'view' => 'Ver', + 'choose' => 'Elegir', ], 'flash' => [ diff --git a/lang/es/posts.php b/lang/es/posts.php index 06c05ef5..67774af0 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -139,12 +139,8 @@ 'settings' => 'Configuración de LinkedIn', 'settings_page' => 'Configuración de la Página de LinkedIn', 'posting_to' => 'Publicando en', - 'variant_label' => 'Tipo de publicación', - 'variant' => [ - 'post' => 'Publicación', - 'carousel' => 'Carrusel', - 'document' => 'Documento', - ], + 'document_title' => 'Título del documento', + 'document_title_placeholder' => 'Se muestra en tu publicación de documento PDF', ], 'pinterest' => [ 'settings' => 'Configuración de Pinterest', @@ -190,6 +186,8 @@ 'no_video_allowed' => 'Este tipo no acepta videos.', 'no_image_allowed' => 'Este tipo acepta solo videos.', 'no_document_allowed' => 'Este tipo no acepta documentos PDF.', + 'no_mixed_media' => 'No se pueden combinar imágenes y un video en la misma publicación.', + 'document_not_alone' => 'Un PDF debe ser el único archivo adjunto.', 'gif_not_allowed' => 'Esta red no acepta GIF. Elimínalo o selecciona otra red.', 'image_too_large' => 'La imagen supera el límite de :max (la tuya es :current).', 'video_too_large' => 'El video supera el límite de :max (el tuyo es :current).', @@ -348,6 +346,7 @@ '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.', + 'document_not_alone' => 'Un PDF debe ser el único archivo adjunto.', '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.', @@ -463,27 +462,11 @@ ], 'linkedin_post' => [ 'label' => 'Post', - 'description' => 'Post estándar con texto y multimedia', - ], - 'linkedin_carousel' => [ - 'label' => 'Carrusel', - 'description' => 'Imágenes deslizables', + 'description' => 'Post estándar — imagen, varias imágenes, video o PDF', ], 'linkedin_page_post' => [ 'label' => 'Post', - 'description' => 'Post estándar con texto y multimedia', - ], - 'linkedin_page_carousel' => [ - 'label' => 'Carrusel', - 'description' => 'Imágenes deslizables', - ], - 'linkedin_document' => [ - 'label' => 'Documento', - 'description' => 'Carrusel PDF deslizable', - ], - 'linkedin_page_document' => [ - 'label' => 'Documento', - 'description' => 'Carrusel PDF deslizable', + 'description' => 'Post estándar — imagen, varias imágenes, video o PDF', ], 'facebook_post' => [ 'label' => 'Post', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 71baa511..73ebee8b 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -15,7 +15,7 @@ 'disconnect' => 'Desconectar', 'descriptions' => [ - 'linkedin' => 'Conecte seu perfil pessoal do LinkedIn', + 'linkedin' => 'Conecte seu perfil do LinkedIn ou página de empresa', 'linkedin-page' => 'Conecte uma página de empresa do LinkedIn', 'x' => 'Conecte sua conta do X (Twitter)', 'tiktok' => 'Conecte sua conta do TikTok', @@ -98,6 +98,12 @@ 'no_pages' => 'Nenhuma página encontrada', 'no_pages_description' => 'Você não é administrador de nenhuma página do LinkedIn.', 'page_label' => 'Página do LinkedIn', + 'select_title' => 'Onde você quer publicar?', + 'select_subtitle' => 'Publique como você mesmo ou escolha uma página de empresa que você administra.', + 'person_tag' => 'Pessoa', + 'organization_tag' => 'Empresa', + 'view' => 'Ver', + 'choose' => 'Escolher', ], 'flash' => [ diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index e936859c..515c8d31 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -139,12 +139,8 @@ 'settings' => 'Configurações do LinkedIn', 'settings_page' => 'Configurações da Página do LinkedIn', 'posting_to' => 'Publicando em', - 'variant_label' => 'Tipo de publicação', - 'variant' => [ - 'post' => 'Post', - 'carousel' => 'Carrossel', - 'document' => 'Documento', - ], + 'document_title' => 'Título do documento', + 'document_title_placeholder' => 'Aparece no seu post de documento PDF', ], 'pinterest' => [ 'settings' => 'Configurações do Pinterest', @@ -190,6 +186,8 @@ 'no_video_allowed' => 'Este tipo não aceita vídeos.', 'no_image_allowed' => 'Este tipo aceita apenas vídeos.', 'no_document_allowed' => 'Este tipo não aceita documentos PDF.', + 'no_mixed_media' => 'Não dá pra combinar imagens e um vídeo no mesmo post.', + 'document_not_alone' => 'Um PDF deve ser o único anexo.', 'gif_not_allowed' => 'Esta rede não aceita GIF. Remova o GIF ou escolha outra rede.', 'image_too_large' => 'A imagem passa do limite de :max (a sua tem :current).', 'video_too_large' => 'O vídeo passa do limite de :max (o seu tem :current).', @@ -348,6 +346,7 @@ '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.', + 'document_not_alone' => 'Um PDF deve ser o único anexo.', '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.', @@ -463,27 +462,11 @@ ], 'linkedin_post' => [ 'label' => 'Post', - 'description' => 'Post padrão com texto e mídia', - ], - 'linkedin_carousel' => [ - 'label' => 'Carrossel', - 'description' => 'Imagens deslizáveis', + 'description' => 'Post padrão — imagem, várias imagens, vídeo ou PDF', ], 'linkedin_page_post' => [ 'label' => 'Post', - 'description' => 'Post padrão com texto e mídia', - ], - 'linkedin_page_carousel' => [ - 'label' => 'Carrossel', - 'description' => 'Imagens deslizáveis', - ], - 'linkedin_document' => [ - 'label' => 'Documento', - 'description' => 'Carrossel PDF deslizável', - ], - 'linkedin_page_document' => [ - 'label' => 'Documento', - 'description' => 'Carrossel PDF deslizável', + 'description' => 'Post padrão — imagem, várias imagens, vídeo ou PDF', ], 'facebook_post' => [ 'label' => 'Post', diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 5295d3db..32f5f711 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -165,9 +165,10 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel :platform="channel.platform" :content-type="channel.contentType" :media="media" + :meta="channel.meta" :disabled="disabled" :preview-only="previewOnly" - @update:content-type="emit('update:contentType', channel.id, $event)" + @update:meta="emit('update:meta', channel.id, $event)" /> // Mirrors `NetworksGrid.vue` from the marketing site — pastel tile bg // + ink 2px border + slight rotation per platform, real PNG logo inside. -// `linkedin-page` / `instagram-facebook` fall back to the base brand -// image and same color since they're variants of the same network. +// `instagram-facebook` falls back to the base brand image and same color +// since it's a variant of the same network. const platformTheme: Record< string, { bg: string; rotate: string; image: string } @@ -70,11 +70,6 @@ const platformTheme: Record< rotate: '-rotate-1', image: '/images/accounts/linkedin.png', }, - 'linkedin-page': { - bg: 'bg-blue-200', - rotate: '-rotate-1', - image: '/images/accounts/linkedin.png', - }, x: { bg: 'bg-amber-200', rotate: 'rotate-2', @@ -177,6 +172,9 @@ const disconnectAccount = (account: ConnectedAccount) => { const needsReconnect = (account: ConnectedAccount): boolean => account.status === 'disconnected' || account.status === 'token_expired'; +const connectEntryFor = (platformValue: string): string => + platformValue === Platform.LinkedInPage ? Platform.LinkedIn : platformValue; + const openConnect = (platformValue: string) => { if (platformValue === Platform.Telegram) { telegramOpen.value = true; @@ -195,7 +193,7 @@ const connectPlatform = (platformValue: string) => { }; const reconnectAccount = (account: ConnectedAccount) => { - openConnect(account.platform); + openConnect(connectEntryFor(account.platform)); }; const CardState = { diff --git a/resources/js/components/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index e6912e26..1d73d91b 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -213,7 +213,7 @@ const getBoards = (account: SocialAccount): PinterestBoard[] => // Image-capability is derived from the SAME media rules the post editor uses // (per content type), never a hardcoded list — facebook_post, tiktok_photo, -// linkedin_carousel etc. all accept multiple images. We cap AI image generation +// linkedin_post etc. all accept multiple images. We cap AI image generation // at MAX_GENERATED_IMAGES regardless of how many a platform technically allows. const MAX_GENERATED_IMAGES = 10; diff --git a/resources/js/components/posts/editor/LinkedInSettings.vue b/resources/js/components/posts/editor/LinkedInSettings.vue index 54f2fa9a..3a1178c8 100644 --- a/resources/js/components/posts/editor/LinkedInSettings.vue +++ b/resources/js/components/posts/editor/LinkedInSettings.vue @@ -3,10 +3,11 @@ import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons import { computed, ref } from 'vue'; import { Avatar } from '@/components/ui/avatar'; -import { getMediaValidationWarning } from '@/composables/useMedia'; +import { Input } from '@/components/ui/input'; +import { getMediaValidationWarning, isDocumentMedia } from '@/composables/useMedia'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; -import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; +import { Platform } from '@/types/platform'; interface SocialAccount { id: string; @@ -21,41 +22,33 @@ interface Props { platform: string; contentType: string; media: MediaItem[]; + meta?: Record; disabled?: boolean; previewOnly?: boolean; } const props = withDefaults(defineProps(), { + meta: () => ({}), disabled: false, previewOnly: false, }); const emit = defineEmits<{ - 'update:contentType': [value: string]; + 'update:meta': [value: Record]; }>(); const open = ref(false); -const isPage = computed(() => props.platform === 'linkedin-page'); +const isPage = computed(() => props.platform === Platform.LinkedInPage); -const variants = computed(() => - isPage.value - ? [ - { value: ContentType.LinkedInPagePost, labelKey: 'posts.form.linkedin.variant.post' }, - { value: ContentType.LinkedInPageCarousel, labelKey: 'posts.form.linkedin.variant.carousel' }, - { value: ContentType.LinkedInPageDocument, labelKey: 'posts.form.linkedin.variant.document' }, - ] - : [ - { value: ContentType.LinkedInPost, labelKey: 'posts.form.linkedin.variant.post' }, - { value: ContentType.LinkedInCarousel, labelKey: 'posts.form.linkedin.variant.carousel' }, - { value: ContentType.LinkedInDocument, labelKey: 'posts.form.linkedin.variant.document' }, - ], -); +// The publish format follows the media; the only thing to configure is the +// title shown on a document (PDF) post, so we only expose it when a PDF is set. +const hasPdf = computed(() => props.media.some((item) => isDocumentMedia(item))); -const pickVariant = (value: string) => { - if (props.disabled) return; - emit('update:contentType', value); -}; +const documentTitle = computed({ + get: () => (props.meta?.document_title as string | undefined) ?? '', + set: (value: string) => emit('update:meta', { ...props.meta, document_title: value || null }), +}); const warning = computed(() => getMediaValidationWarning(props.contentType, props.media)); @@ -94,23 +87,14 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop -
-

{{ $t('posts.form.linkedin.variant_label') }}

-
- -
+
+

{{ $t('posts.form.linkedin.document_title') }}

+

0) { return { key: 'no_document_allowed', params: {} }; } + if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) { + return { key: 'no_mixed_media', params: {} }; + } + if (rules.acceptDocuments && documents.length > 0 && total > 1) { + return { key: 'document_not_alone', params: {} }; + } if (! rules.acceptsGif && gifs.length > 0) { return { key: 'gif_not_allowed', params: {} }; } diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 24bdbdfc..6840f6f3 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -60,39 +60,18 @@ const CONTENT_TYPE_RULES: Record = { aspectRatioMin: 0.5, aspectRatioMax: 0.6, }, - // LinkedIn + // LinkedIn — one type per account kind; the publish format (single image, + // multi-image, video, or PDF document) is inferred from the attached media. + // Images (up to 20) XOR one video XOR one PDF, never mixed. linkedin_post: { - maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, - }, - linkedin_carousel: { - maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 5 * MB, - aspectRatioMin: 0.5, aspectRatioMax: 1, + maxFiles: 20, acceptImages: true, acceptVideos: true, acceptDocuments: true, + requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, + maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, }, linkedin_page_post: { - maxFiles: 1, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: false, - maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, - }, - linkedin_page_carousel: { - maxFiles: 20, acceptImages: true, acceptVideos: false, requiresMedia: true, - acceptsGif: false, - maxImageBytes: 5 * MB, - aspectRatioMin: 0.5, aspectRatioMax: 1, - }, - // Document (PDF) posts — the swipeable LinkedIn carousel. One PDF, up to 100MB. - linkedin_document: { - maxFiles: 1, acceptImages: false, acceptVideos: false, acceptDocuments: true, requiresMedia: true, - acceptsGif: false, - maxDocumentBytes: 100 * MB, - }, - linkedin_page_document: { - maxFiles: 1, acceptImages: false, acceptVideos: false, acceptDocuments: true, requiresMedia: true, - acceptsGif: false, - maxDocumentBytes: 100 * MB, + maxFiles: 20, acceptImages: true, acceptVideos: true, acceptDocuments: true, + requiresMedia: false, acceptsGif: false, forbidsMixedMedia: true, + maxImageBytes: 5 * MB, maxVideoBytes: 5 * GB, maxVideoDurationSec: 10 * 60, maxDocumentBytes: 100 * MB, }, // TikTok diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index 112e3cc0..4b0baf68 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -39,8 +39,8 @@ const PLATFORM_CONTENT_TYPES: Record = { 'instagram_reel', 'instagram_story', ], - linkedin: ['linkedin_post', 'linkedin_carousel', 'linkedin_document'], - 'linkedin-page': ['linkedin_page_post', 'linkedin_page_carousel', 'linkedin_page_document'], + linkedin: ['linkedin_post'], + 'linkedin-page': ['linkedin_page_post'], facebook: ['facebook_post', 'facebook_reel', 'facebook_story'], tiktok: ['tiktok_video'], youtube: ['youtube_short'], diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index 1390a817..6f24118d 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -24,8 +24,8 @@ export const PLATFORM_VARIANTS: Record = { [Platform.Facebook]: [ContentType.FacebookPost, ContentType.FacebookReel, ContentType.FacebookStory], [Platform.Instagram]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory], [Platform.InstagramFacebook]: [ContentType.InstagramFeed, ContentType.InstagramReel, ContentType.InstagramStory], - [Platform.LinkedIn]: [ContentType.LinkedInPost, ContentType.LinkedInCarousel, ContentType.LinkedInDocument], - [Platform.LinkedInPage]: [ContentType.LinkedInPagePost, ContentType.LinkedInPageCarousel, ContentType.LinkedInPageDocument], + [Platform.LinkedIn]: [ContentType.LinkedInPost], + [Platform.LinkedInPage]: [ContentType.LinkedInPagePost], [Platform.TikTok]: [ContentType.TikTokVideo, ContentType.TikTokPhoto], [Platform.Pinterest]: [ContentType.PinterestPin, ContentType.PinterestVideoPin, ContentType.PinterestCarousel], }; @@ -101,6 +101,7 @@ export const getMediaIncompatibilityReason = ( 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.acceptDocuments && documents.length > 0 && total > 1) return trans('posts.edit.compliance.document_not_alone'); 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) }); diff --git a/resources/js/pages/accounts/LinkedInPageSelect.vue b/resources/js/pages/accounts/LinkedInPageSelect.vue deleted file mode 100644 index b79c1d07..00000000 --- a/resources/js/pages/accounts/LinkedInPageSelect.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - diff --git a/resources/js/pages/accounts/LinkedInSelect.vue b/resources/js/pages/accounts/LinkedInSelect.vue new file mode 100644 index 00000000..c728986e --- /dev/null +++ b/resources/js/pages/accounts/LinkedInSelect.vue @@ -0,0 +1,186 @@ + + + diff --git a/resources/js/types/content-type.ts b/resources/js/types/content-type.ts index a6c2b9e8..22462b69 100644 --- a/resources/js/types/content-type.ts +++ b/resources/js/types/content-type.ts @@ -3,11 +3,7 @@ export const ContentType = { InstagramStory: 'instagram_story', InstagramReel: 'instagram_reel', LinkedInPost: 'linkedin_post', - LinkedInCarousel: 'linkedin_carousel', - LinkedInDocument: 'linkedin_document', LinkedInPagePost: 'linkedin_page_post', - LinkedInPageCarousel: 'linkedin_page_carousel', - LinkedInPageDocument: 'linkedin_page_document', FacebookPost: 'facebook_post', FacebookReel: 'facebook_reel', FacebookStory: 'facebook_story', diff --git a/routes/app.php b/routes/app.php index 01253dc2..fff701d0 100644 --- a/routes/app.php +++ b/routes/app.php @@ -36,7 +36,6 @@ use App\Http\Controllers\Auth\InstagramController; use App\Http\Controllers\Auth\InstagramFacebookController; use App\Http\Controllers\Auth\LinkedInController; -use App\Http\Controllers\Auth\LinkedInPageController; use App\Http\Controllers\Auth\MastodonController; use App\Http\Controllers\Auth\PinterestController; use App\Http\Controllers\Auth\SocialController; @@ -80,11 +79,8 @@ Route::middleware(['auth'])->group(function () { Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect'); Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback'); - - Route::get('connect/linkedin-page', [LinkedInPageController::class, 'connect'])->name('app.social.linkedin-page.connect'); - Route::get('accounts/linkedin-page/callback', [LinkedInPageController::class, 'callback'])->name('app.social.linkedin-page.callback'); - Route::get('accounts/linkedin-page/select', [LinkedInPageController::class, 'selectPage'])->name('app.social.linkedin-page.select-page'); - Route::post('accounts/linkedin-page/select', [LinkedInPageController::class, 'select'])->name('app.social.linkedin-page.select'); + Route::get('accounts/linkedin/select', [LinkedInController::class, 'selectIdentity'])->name('app.social.linkedin.select-identity'); + Route::post('accounts/linkedin/select', [LinkedInController::class, 'select'])->name('app.social.linkedin.select'); Route::get('connect/x', [XController::class, 'connect'])->name('app.social.x.connect'); Route::get('accounts/x/callback', [XController::class, 'callback'])->name('app.social.x.callback'); diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index dd16ca79..b41aaef4 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -59,7 +59,7 @@ 'content' => 'Check our latest deck', 'platforms' => [[ 'social_account_id' => $linkedin->id, - 'content_type' => ContentType::LinkedInDocument->value, + 'content_type' => ContentType::LinkedInPost->value, 'meta' => ['document_title' => 'Q2 Report'], ]], ]) @@ -83,52 +83,28 @@ ]); $platform = PostPlatform::factory()->create([ 'post_id' => $post->id, 'social_account_id' => $linkedin->id, - 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + '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::LinkedInDocument->value]], + 'platforms' => [['id' => $platform->id, 'content_type' => ContentType::LinkedInPost->value]], ]) ->assertOk(); Queue::assertPushed(PublishPost::class); }); -it('rejects publishing a LinkedIn document post whose media is an image, not a PDF', function () { +it('rejects publishing a LinkedIn post that mixes a PDF with an image', 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', - ]], + 'media' => [ + ['id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf', 'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf'], + ['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, @@ -159,7 +135,7 @@ ]); $platform = PostPlatform::factory()->create([ 'post_id' => $post->id, 'social_account_id' => $linkedin->id, - 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true, ]); $this->withHeaders($this->headers) @@ -172,31 +148,6 @@ 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('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]); @@ -212,7 +163,7 @@ ]); $linkedinPlatform = PostPlatform::factory()->create([ 'post_id' => $post->id, 'social_account_id' => $linkedin->id, - 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true, ]); $xPlatform = PostPlatform::factory()->create([ 'post_id' => $post->id, 'social_account_id' => $x->id, @@ -223,7 +174,7 @@ ->putJson(route('api.posts.update', $post), [ 'status' => PostStatus::Publishing->value, 'platforms' => [ - ['id' => $linkedinPlatform->id, 'content_type' => ContentType::LinkedInDocument->value], + ['id' => $linkedinPlatform->id, 'content_type' => ContentType::LinkedInPost->value], ['id' => $xPlatform->id, 'content_type' => ContentType::XPost->value], ], ]) diff --git a/tests/Feature/Automation/Node/GenerateNodeTest.php b/tests/Feature/Automation/Node/GenerateNodeTest.php index 93e37e44..f8fa5673 100644 --- a/tests/Feature/Automation/Node/GenerateNodeTest.php +++ b/tests/Feature/Automation/Node/GenerateNodeTest.php @@ -285,7 +285,7 @@ $action = app(RunGenerateNode::class); $accountsConfig = [ - ['social_account_id' => '1', 'content_type' => ContentType::LinkedInPost->value, 'meta' => []], + ['social_account_id' => '1', 'content_type' => ContentType::PinterestPin->value, 'meta' => []], ['social_account_id' => '2', 'content_type' => ContentType::InstagramReel->value, 'meta' => []], ]; diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 31c96aff..cb3ff6d6 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -5,6 +5,7 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\PostPlatform\Status as PlatformStatus; +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; use App\Enums\UserWorkspace\Role; use App\Events\PostPlatformStatusUpdated; @@ -20,6 +21,7 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; +use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; use Carbon\Carbon; use Illuminate\Support\Facades\Bus; @@ -81,6 +83,37 @@ expect($this->postPlatform->platform_url)->toBe('https://linkedin.com/post/123'); }); +test('publish job dispatches a linkedin-page post to the page publisher', function () { + Event::fake(); + + $workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $pageAccount = SocialAccount::factory()->linkedinPage()->create(['workspace_id' => $workspace->id]); + $post = Post::factory()->scheduled()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $this->user->id, + ]); + $pagePostPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $pageAccount->id, + 'platform' => Platform::LinkedInPage, + 'content_type' => ContentType::LinkedInPagePost, + 'enabled' => true, + ]); + + $publisher = Mockery::mock(LinkedInPagePublisher::class); + $publisher->shouldReceive('publish')->once()->andReturn([ + 'id' => 'org-post-1', + 'url' => 'https://linkedin.com/company/post/1', + ]); + $this->app->instance(LinkedInPagePublisher::class, $publisher); + + (new PublishToSocialPlatform($pagePostPlatform))->handle(); + + $pagePostPlatform->refresh(); + expect($pagePostPlatform->status)->toBe(PlatformStatus::Published); + expect($pagePostPlatform->platform_post_id)->toBe('org-post-1'); +}); + test('publish job runs the real document flow end-to-end for a LinkedIn PDF post', function () { Event::fake(); @@ -89,7 +122,7 @@ 'platform_user_id' => 'person-xyz', 'token_expires_at' => now()->addDays(60), ]); - $this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'content' => 'Our deck', 'media' => [[ diff --git a/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php b/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php index 46a36e7e..1b54bcc0 100644 --- a/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php +++ b/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php @@ -106,7 +106,7 @@ $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, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true, ]); $response = TryPostServer::actingAs($this->user) diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 154f38f7..e4681c7f 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -62,7 +62,7 @@ 'content' => 'Check our latest deck', 'platforms' => [[ 'social_account_id' => $linkedin->id, - 'content_type' => ContentType::LinkedInDocument->value, + 'content_type' => ContentType::LinkedInPost->value, 'meta' => ['document_title' => 'Q2 Report'], ]], ]); @@ -187,7 +187,7 @@ ]); PostPlatform::factory()->create([ 'post_id' => $post->id, 'social_account_id' => $linkedin->id, - 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true, ]); $uploadToken = (string) Str::uuid(); @@ -233,7 +233,7 @@ ]); PostPlatform::factory()->create([ 'post_id' => $post->id, 'social_account_id' => $linkedin->id, - 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInDocument, 'enabled' => true, + 'platform' => Platform::LinkedIn, 'content_type' => ContentType::LinkedInPost, 'enabled' => true, ]); $response = TryPostServer::actingAs($this->user) @@ -243,55 +243,27 @@ Queue::assertPushed(PublishPost::class); }); -test('publish post rejects a LinkedIn document whose media is an image, not a PDF', function () { +test('publish post rejects a LinkedIn post that mixes a PDF with an image', 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', - ]], + 'media' => [ + ['id' => 'doc-1', 'path' => 'medias/deck.pdf', 'url' => 'https://example.com/deck.pdf', 'type' => 'document', 'mime_type' => 'application/pdf', 'original_filename' => 'deck.pdf'], + ['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('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]], - ]); + ->tool(PublishPostTool::class, ['post_id' => $post->id]); - $response->assertHasErrors(['Post does not support PDF documents.']); + $response->assertHasErrors(['A PDF document must be the only attachment.']); }); test('publish post succeeds for a Discord platform with a channel', function () { diff --git a/tests/Feature/Migrations/CollapseLinkedInContentTypesTest.php b/tests/Feature/Migrations/CollapseLinkedInContentTypesTest.php new file mode 100644 index 00000000..c2afd201 --- /dev/null +++ b/tests/Feature/Migrations/CollapseLinkedInContentTypesTest.php @@ -0,0 +1,51 @@ +create(['content_type' => ContentType::LinkedInPost]); + + foreach (['linkedin_carousel', 'linkedin_document'] as $legacy) { + DB::table('post_platforms')->where('id', $platform->id)->update(['content_type' => $legacy]); + + collapseLinkedInContentTypesMigration()->up(); + + expect(DB::table('post_platforms')->where('id', $platform->id)->value('content_type')) + ->toBe('linkedin_post'); + } +}); + +test('the migration collapses retired linkedin page content types onto linkedin_page_post', function () { + $platform = PostPlatform::factory()->create(['content_type' => ContentType::LinkedInPagePost]); + + foreach (['linkedin_page_carousel', 'linkedin_page_document'] as $legacy) { + DB::table('post_platforms')->where('id', $platform->id)->update(['content_type' => $legacy]); + + collapseLinkedInContentTypesMigration()->up(); + + expect(DB::table('post_platforms')->where('id', $platform->id)->value('content_type')) + ->toBe('linkedin_page_post'); + } +}); + +test('the migration leaves other platforms content types untouched', function () { + $xPost = PostPlatform::factory()->create(['content_type' => ContentType::XPost]); + + collapseLinkedInContentTypesMigration()->up(); + + expect(DB::table('post_platforms')->where('id', $xPost->id)->value('content_type')) + ->toBe(ContentType::XPost->value); +}); diff --git a/tests/Feature/Services/LinkedInPublisherTest.php b/tests/Feature/Services/LinkedInPublisherTest.php deleted file mode 100644 index 7f096426..00000000 --- a/tests/Feature/Services/LinkedInPublisherTest.php +++ /dev/null @@ -1,219 +0,0 @@ -workspace = Workspace::factory()->create(); - $this->socialAccount = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::LinkedIn, - 'platform_user_id' => 'linkedin-123', - 'access_token' => 'test-token', - 'token_expires_at' => now()->addDays(30), - ]); - $this->post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'content' => 'Test LinkedIn post']); - $this->postPlatform = PostPlatform::factory()->create([ - 'post_id' => $this->post->id, - 'social_account_id' => $this->socialAccount->id, - 'content_type' => ContentType::LinkedInPost, - ]); -}); - -test('linkedin publisher publishes text only post', function () { - Http::fake([ - '*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']), - ]); - - $publisher = new LinkedInPublisher; - $result = $publisher->publish($this->postPlatform); - - expect($result['id'])->toBe('urn:li:share:123456'); - expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:123456'); -}); - -test('linkedin publisher throws exception for carousel without images', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInCarousel]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(Exception::class, 'No valid images for LinkedIn carousel'); -}); - -test('linkedin publisher throws token expired exception on oauth error', function () { - Http::fake([ - '*' => Http::response([ - 'code' => 'REVOKED_ACCESS_TOKEN', - 'message' => 'Token has been revoked', - ], 401), - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(TokenExpiredException::class); -}); - -test('linkedin publisher throws token expired exception on expired token', function () { - Http::fake([ - '*' => Http::response([ - 'code' => 'EXPIRED_ACCESS_TOKEN', - 'message' => 'Token has expired', - ], 401), - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(TokenExpiredException::class); -}); - -test('linkedin publisher throws token expired exception on invalid token', function () { - Http::fake([ - '*' => Http::response([ - 'code' => 'INVALID_ACCESS_TOKEN', - 'message' => 'Token is invalid', - ], 401), - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(TokenExpiredException::class); -}); - -test('linkedin publisher refreshes token when expired', function () { - $this->socialAccount->update([ - 'token_expires_at' => now()->subHour(), - 'refresh_token' => 'refresh-token-123', - ]); - - Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'expires_in' => 3600, - ], 200), - '*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']), - ]); - - $publisher = new LinkedInPublisher; - $result = $publisher->publish($this->postPlatform); - - expect($result['id'])->toBe('urn:li:share:123456'); - $this->socialAccount->refresh(); - expect($this->socialAccount->access_token)->toBe('new-access-token'); -}); - -test('linkedin publisher throws exception when no refresh token', function () { - $this->socialAccount->update([ - 'token_expires_at' => now()->subHour(), - 'refresh_token' => null, - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(TokenExpiredException::class, 'No refresh token available'); -}); - -test('linkedin publisher throws exception on api error', function () { - Http::fake([ - '*' => Http::response([ - 'code' => 'INVALID_REQUEST', - 'message' => 'Invalid request parameters', - ], 400), - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(Exception::class); -}); - -test('linkedin publisher throws exception for unsupported content type', function () { - $this->postPlatform->update(['content_type' => ContentType::InstagramReel]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(Exception::class, 'Unsupported LinkedIn content type'); -}); - -test('linkedin publisher handles 401 status as token error', function () { - Http::fake([ - '*' => Http::response([ - 'message' => 'Unauthorized', - ], 401), - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(TokenExpiredException::class); -}); - -test('linkedin publisher handles token refresh failure', function () { - $this->socialAccount->update([ - 'token_expires_at' => now()->subHour(), - 'refresh_token' => 'refresh-token-123', - ]); - - Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ - 'code' => 'INVALID_REFRESH_TOKEN', - 'message' => 'Invalid refresh token', - ], 400), - ]); - - $publisher = new LinkedInPublisher; - - expect(fn () => $publisher->publish($this->postPlatform)) - ->toThrow(Exception::class); -}); - -test('linkedin publisher refreshes token when expiring soon', function () { - $this->socialAccount->update([ - 'token_expires_at' => now()->addMinutes(5), - 'refresh_token' => 'refresh-token-123', - ]); - - Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'expires_in' => 3600, - ], 200), - '*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']), - ]); - - $publisher = new LinkedInPublisher; - $result = $publisher->publish($this->postPlatform); - - expect($result['id'])->toBe('urn:li:share:123456'); - $this->socialAccount->refresh(); - expect($this->socialAccount->access_token)->toBe('new-access-token'); -}); - -test('linkedin publisher handles empty post id header', function () { - Http::fake([ - '*/rest/posts' => Http::response(null, 201), - ]); - - $publisher = new LinkedInPublisher; - $result = $publisher->publish($this->postPlatform); - - // When header is empty, id will be empty string or 'unknown' - expect($result)->toHaveKey('id'); - expect($result)->toHaveKey('url'); -}); diff --git a/tests/Feature/Services/Social/LinkedInPagePublisherTest.php b/tests/Feature/Services/Social/LinkedInPagePublisherTest.php index 9f7313f5..c1b5df6e 100644 --- a/tests/Feature/Services/Social/LinkedInPagePublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPagePublisherTest.php @@ -47,7 +47,7 @@ test('linkedin page publisher can publish text-only post', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -69,7 +69,7 @@ test('linkedin page publisher uses organization urn', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -90,7 +90,7 @@ test('linkedin page publisher uses correct headers', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -107,7 +107,7 @@ test('linkedin page publisher throws exception on api error', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response([ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response([ 'message' => 'Invalid request', 'status' => 400, ], 400), @@ -119,11 +119,11 @@ test('linkedin page publisher throws token expired exception on auth error after retry', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response([ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response([ 'code' => 'EXPIRED_ACCESS_TOKEN', 'message' => 'The token used in the request has expired', ], 401), - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ 'error' => 'invalid_grant', 'error_description' => 'The refresh token is invalid', ], 400), @@ -137,12 +137,12 @@ $this->socialAccount->update(['token_expires_at' => now()->subHour()]); Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ 'access_token' => 'new-access-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 5184000, ], 200), - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -171,7 +171,7 @@ $this->socialAccount->update(['token_expires_at' => now()->subHour()]); Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ 'error' => 'invalid_grant', 'error_description' => 'The refresh token is invalid', ], 400), @@ -185,7 +185,7 @@ $this->post->update(['content' => '']); Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -199,16 +199,9 @@ }); }); -test('linkedin page publisher throws exception for unsupported content type', function () { - $this->postPlatform->update(['content_type' => ContentType::InstagramFeed]); - - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(Exception::class, 'Unsupported LinkedIn Page content type'); -}); - test('linkedin page publisher builds correct company url when username present', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -222,7 +215,7 @@ $this->socialAccount->update(['username' => null]); Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -286,7 +279,7 @@ test('linkedin page publisher can publish a document (pdf carousel) using organization urn', function () { $this->postPlatform->update([ - 'content_type' => ContentType::LinkedInPageDocument, + 'content_type' => ContentType::LinkedInPagePost, 'meta' => ['document_title' => 'Company Deck'], ]); $this->post->update([ @@ -348,7 +341,7 @@ }); test('linkedin page publisher throws and does not post when document processing fails', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInPageDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPagePost]); $this->post->update([ 'media' => [[ 'id' => 'doc-media-1', 'path' => 'media/2026-01/company-deck.pdf', diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index a1ee47e7..bf089ffb 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -42,7 +42,7 @@ test('linkedin publisher can publish text-only post', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -64,7 +64,7 @@ test('linkedin publisher uses correct headers', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -81,7 +81,7 @@ test('linkedin publisher throws exception on api error', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response([ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([ 'message' => 'Invalid request', 'status' => 400, ], 400), @@ -93,11 +93,11 @@ test('linkedin publisher throws token expired exception on auth error after retry', function () { Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response([ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response([ 'code' => 'EXPIRED_ACCESS_TOKEN', 'message' => 'The token used in the request has expired', ], 401), - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ 'error' => 'invalid_grant', 'error_description' => 'The refresh token is invalid', ], 400), @@ -111,12 +111,12 @@ $this->socialAccount->update(['token_expires_at' => now()->subHour()]); Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ 'access_token' => 'new-access-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 5184000, ], 200), - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -145,7 +145,7 @@ $this->socialAccount->update(['token_expires_at' => now()->subHour()]); Http::fake([ - 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ 'error' => 'invalid_grant', 'error_description' => 'The refresh token is invalid', ], 400), @@ -159,7 +159,7 @@ $this->post->update(['content' => '']); Http::fake([ - 'https://api.linkedin.com/rest/posts' => Http::response(null, 201, [ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, [ 'x-restli-id' => 'urn:li:share:1234567890', ]), ]); @@ -173,13 +173,6 @@ }); }); -test('linkedin publisher throws exception for unsupported content type', function () { - $this->postPlatform->update(['content_type' => ContentType::InstagramFeed]); - - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(Exception::class, 'Unsupported LinkedIn content type'); -}); - test('linkedin publisher can publish post with image', function () { $this->post->update([ 'media' => [ @@ -231,7 +224,7 @@ }); test('linkedin publisher can publish carousel with multiple images', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInCarousel]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'media' => [ [ @@ -383,7 +376,7 @@ test('linkedin publisher can publish a document (pdf carousel) with a title', function () { $this->postPlatform->update([ - 'content_type' => ContentType::LinkedInDocument, + 'content_type' => ContentType::LinkedInPost, 'meta' => ['document_title' => 'My Slides'], ]); $this->post->update([ @@ -448,7 +441,7 @@ }); test('linkedin publisher document title falls back to the file name', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'media' => [ [ @@ -497,7 +490,7 @@ }); test('linkedin publisher waits for document processing before posting', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'media' => [ [ @@ -540,7 +533,7 @@ }); test('linkedin publisher throws and does not post when document processing fails', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'media' => [ [ @@ -580,7 +573,7 @@ }); test('linkedin publisher throws and does not post when document init fails', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'media' => [[ 'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf', @@ -603,7 +596,7 @@ }); test('linkedin publisher throws when document init response is missing the urn', function () { - $this->postPlatform->update(['content_type' => ContentType::LinkedInDocument]); + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); $this->post->update([ 'media' => [[ 'id' => 'doc-media-1', 'path' => 'media/2026-01/deck.pdf', @@ -625,3 +618,45 @@ Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts')); }); + +test('linkedin publisher treats a 401 response without an error code as a token error', function () { + Http::fake([ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(['message' => 'Unauthorized'], 401), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class); +}); + +test('linkedin publisher refreshes the token when it is expiring soon', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->addMinutes(5), + 'refresh_token' => 'refresh-token-123', + ]); + + Http::fake([ + config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 3600, + ], 200), + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:soon']), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:soon'); + $this->socialAccount->refresh(); + expect($this->socialAccount->access_token)->toBe('new-access-token'); +}); + +test('linkedin publisher falls back to an empty id and null url when the post id header is missing', function () { + Http::fake([ + config('trypost.platforms.linkedin.api').'/rest/posts' => Http::response(null, 201), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe(''); + expect($result['url'])->toBeNull(); +}); diff --git a/tests/Feature/Services/Social/LinkedInTokenSynchronizerTest.php b/tests/Feature/Services/Social/LinkedInTokenSynchronizerTest.php deleted file mode 100644 index 85868af4..00000000 --- a/tests/Feature/Services/Social/LinkedInTokenSynchronizerTest.php +++ /dev/null @@ -1,270 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); - $this->synchronizer = new LinkedInTokenSynchronizer; -}); - -test('syncs tokens from linkedin personal to linkedin page', function () { - $linkedInUserId = 'linkedin-user-123'; - - $personalAccount = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => $linkedInUserId, - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'token_expires_at' => now()->addDays(60), - ]); - - $pageAccount = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-123', - 'access_token' => 'old-access-token', - 'refresh_token' => 'old-refresh-token', - 'token_expires_at' => now()->subDay(), - 'meta' => [ - 'organization_id' => 'org-123', - 'admin_user_id' => $linkedInUserId, - 'admin_name' => 'Test User', - ], - ]); - - $this->synchronizer->syncTokens($personalAccount); - - $pageAccount->refresh(); - expect($pageAccount->access_token)->toBe('new-access-token'); - expect($pageAccount->refresh_token)->toBe('new-refresh-token'); - expect($pageAccount->token_expires_at->toDateString())->toBe(now()->addDays(60)->toDateString()); -}); - -test('syncs tokens from linkedin page to linkedin personal', function () { - $linkedInUserId = 'linkedin-user-456'; - - $personalAccount = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => $linkedInUserId, - 'access_token' => 'old-access-token', - 'refresh_token' => 'old-refresh-token', - 'token_expires_at' => now()->subDay(), - ]); - - $pageAccount = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-456', - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'token_expires_at' => now()->addDays(60), - 'meta' => [ - 'organization_id' => 'org-456', - 'admin_user_id' => $linkedInUserId, - 'admin_name' => 'Test User', - ], - ]); - - $this->synchronizer->syncTokens($pageAccount); - - $personalAccount->refresh(); - expect($personalAccount->access_token)->toBe('new-access-token'); - expect($personalAccount->refresh_token)->toBe('new-refresh-token'); - expect($personalAccount->token_expires_at->toDateString())->toBe(now()->addDays(60)->toDateString()); -}); - -test('marks disconnected account as connected after sync', function () { - $linkedInUserId = 'linkedin-user-789'; - - $personalAccount = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => $linkedInUserId, - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'token_expires_at' => now()->addDays(60), - ]); - - $pageAccount = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-789', - 'access_token' => 'old-access-token', - 'refresh_token' => 'old-refresh-token', - 'status' => Status::Disconnected, - 'meta' => [ - 'organization_id' => 'org-789', - 'admin_user_id' => $linkedInUserId, - 'admin_name' => 'Test User', - ], - ]); - - $this->synchronizer->syncTokens($personalAccount); - - $pageAccount->refresh(); - expect($pageAccount->status)->toBe(Status::Connected); -}); - -test('does not sync when no linked account exists', function () { - $personalAccount = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'linkedin-user-solo', - 'access_token' => 'access-token', - 'refresh_token' => 'refresh-token', - ]); - - // This should not throw any errors - $this->synchronizer->syncTokens($personalAccount); - - // Just verify the account still has its original tokens - expect($personalAccount->access_token)->toBe('access-token'); -}); - -test('syncs tokens to every linked page admin\'d by the same user', function () { - $linkedInUserId = 'linkedin-user-multi'; - - $personalAccount = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => $linkedInUserId, - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'token_expires_at' => now()->addDays(60), - ]); - - $pageOne = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-one', - 'access_token' => 'old-token-1', - 'refresh_token' => 'old-refresh-1', - 'meta' => [ - 'organization_id' => 'org-one', - 'admin_user_id' => $linkedInUserId, - 'admin_name' => 'Test User', - ], - ]); - - $pageTwo = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-two', - 'access_token' => 'old-token-2', - 'refresh_token' => 'old-refresh-2', - 'meta' => [ - 'organization_id' => 'org-two', - 'admin_user_id' => $linkedInUserId, - 'admin_name' => 'Test User', - ], - ]); - - $unrelatedPage = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-other-admin', - 'access_token' => 'untouched-token', - 'refresh_token' => 'untouched-refresh', - 'meta' => [ - 'organization_id' => 'org-other-admin', - 'admin_user_id' => 'someone-else', - 'admin_name' => 'Other User', - ], - ]); - - $this->synchronizer->syncTokens($personalAccount); - - $pageOne->refresh(); - $pageTwo->refresh(); - $unrelatedPage->refresh(); - - expect($pageOne->access_token)->toBe('new-access-token'); - expect($pageOne->refresh_token)->toBe('new-refresh-token'); - expect($pageTwo->access_token)->toBe('new-access-token'); - expect($pageTwo->refresh_token)->toBe('new-refresh-token'); - expect($unrelatedPage->access_token)->toBe('untouched-token'); -}); - -test('does not sync across different workspaces', function () { - $linkedInUserId = 'linkedin-user-cross'; - $otherWorkspace = Workspace::factory()->create(['user_id' => $this->user->id]); - - $personalAccount = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => $linkedInUserId, - 'access_token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - ]); - - $pageAccountOtherWorkspace = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $otherWorkspace->id, - 'platform_user_id' => 'org-other', - 'access_token' => 'old-access-token', - 'refresh_token' => 'old-refresh-token', - 'meta' => [ - 'organization_id' => 'org-other', - 'admin_user_id' => $linkedInUserId, - 'admin_name' => 'Test User', - ], - ]); - - $this->synchronizer->syncTokens($personalAccount); - - $pageAccountOtherWorkspace->refresh(); - // Should NOT have been synced because it's in a different workspace - expect($pageAccountOtherWorkspace->access_token)->toBe('old-access-token'); -}); - -test('does not sync across different LinkedIn admins inside the same workspace', function () { - // Scenario: multi-user workspace. Owner connects their own LinkedIn - // (personal + page they admin); a teammate later connects their - // company's LinkedIn Page that they admin. Two distinct LinkedIn - // user identities coexist in the same workspace and must NOT share - // tokens — each token belongs to whoever authorized it at LinkedIn. - - $ownerLinkedInId = 'linkedin-user-owner'; - $clientLinkedInId = 'linkedin-user-client'; - - $ownerPersonal = SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => $ownerLinkedInId, - 'access_token' => 'owner-new-access-token', - 'refresh_token' => 'owner-new-refresh-token', - 'token_expires_at' => now()->addDays(60), - ]); - - $ownerPage = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-owner', - 'access_token' => 'owner-old-access-token', - 'refresh_token' => 'owner-old-refresh-token', - 'meta' => [ - 'organization_id' => 'org-owner', - 'admin_user_id' => $ownerLinkedInId, - 'admin_name' => 'Owner', - ], - ]); - - $clientPage = SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'org-client', - 'access_token' => 'client-untouched-access-token', - 'refresh_token' => 'client-untouched-refresh-token', - 'meta' => [ - 'organization_id' => 'org-client', - 'admin_user_id' => $clientLinkedInId, - 'admin_name' => 'Client', - ], - ]); - - $this->synchronizer->syncTokens($ownerPersonal); - - // Owner's own page got the new token. - $ownerPage->refresh(); - expect($ownerPage->access_token)->toBe('owner-new-access-token'); - expect($ownerPage->refresh_token)->toBe('owner-new-refresh-token'); - - // Client's page (different admin_user_id) was NOT touched even - // though it lives in the same workspace. - $clientPage->refresh(); - expect($clientPage->access_token)->toBe('client-untouched-access-token'); - expect($clientPage->refresh_token)->toBe('client-untouched-refresh-token'); -}); diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 953f7ad1..c33fe998 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -9,6 +9,8 @@ use App\Models\User; use App\Models\Workspace; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; +use Inertia\Testing\AssertableInertia as Assert; use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\User as SocialiteUser; @@ -19,7 +21,24 @@ $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); }); -test('linkedin connect redirects to oauth provider', function () { +/** + * Build a Socialite user for the LinkedIn person behind the OAuth grant. + */ +function linkedInSocialiteUser(string $id = 'person-123'): SocialiteUser +{ + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn($id); + $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'test-access-token'; + $socialiteUser->refreshToken = 'test-refresh-token'; + $socialiteUser->expiresIn = 5184000; // 60 days + $socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social']; + + return $socialiteUser; +} + +test('linkedin connect redirects to oauth provider via the openid driver', function () { $driverMock = Mockery::mock(); $driverMock->shouldReceive('scopes')->andReturnSelf(); $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ @@ -27,7 +46,7 @@ ])); Socialite::shouldReceive('driver') - ->with('linkedin') + ->with('linkedin-openid') ->andReturn($driverMock); $response = $this->actingAs($this->user) @@ -40,12 +59,12 @@ }); /** - * Mock the LinkedIn Socialite driver, hit /connect, and return the scopes - * the controller requested. + * Mock the openid driver, hit connect, and return the scopes the controller asked for. * * @return array */ -$captureConnectScopes = function (object $test): array { +function captureLinkedInConnectScopes(object $test): array +{ $captured = []; $driverMock = Mockery::mock(); @@ -60,68 +79,250 @@ 'getTargetUrl' => 'https://www.linkedin.com/oauth/v2/authorization?test=1', ])); - Socialite::shouldReceive('driver') - ->with('linkedin') - ->andReturn($driverMock); + Socialite::shouldReceive('driver')->with('linkedin-openid')->andReturn($driverMock); $test->actingAs($test->user) ->withHeader('X-Inertia', 'true') ->get(route('app.social.linkedin.connect')); return $captured; -}; +} -test('linkedin connect requests the default scope set', function () use ($captureConnectScopes) { +test('linkedin connect requests the union of personal and organization scopes', function () { + config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social']]); + config(['trypost.platforms.linkedin-page.scopes' => ['openid', 'profile', 'email', 'w_organization_social', 'r_organization_social', 'rw_organization_admin', 'w_member_social']]); + + expect(captureLinkedInConnectScopes($this))->toEqualCanonicalizing([ + 'openid', 'profile', 'email', 'w_member_social', + 'w_organization_social', 'r_organization_social', 'rw_organization_admin', + ]); +}); + +test('connect requests only personal scopes when company pages are disabled', function () { + config(['trypost.platforms.linkedin.enabled' => true]); + config(['trypost.platforms.linkedin-page.enabled' => false]); config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social']]); - expect($captureConnectScopes($this))->toEqualCanonicalizing([ + expect(captureLinkedInConnectScopes($this))->toEqualCanonicalizing([ 'openid', 'profile', 'email', 'w_member_social', ]); }); -test('linkedin connect requests the scopes configured via LINKEDIN_SCOPES', function () use ($captureConnectScopes) { - // Operators whose LinkedIn app has legacy/enterprise products approved - // override the default set via LINKEDIN_SCOPES (exploded into the config - // array), e.g. re-adding r_basicprofile to restore the vanityName lookup. - config(['trypost.platforms.linkedin.scopes' => ['openid', 'profile', 'email', 'w_member_social', 'r_basicprofile']]); +test('connect requests only organization scopes when the personal profile is disabled', function () { + config(['trypost.platforms.linkedin.enabled' => false]); + config(['trypost.platforms.linkedin-page.enabled' => true]); + config(['trypost.platforms.linkedin-page.scopes' => ['openid', 'w_organization_social', 'r_organization_social', 'rw_organization_admin']]); - expect($captureConnectScopes($this))->toEqualCanonicalizing([ - 'openid', 'profile', 'email', 'w_member_social', 'r_basicprofile', + expect(captureLinkedInConnectScopes($this))->toEqualCanonicalizing([ + 'openid', 'w_organization_social', 'r_organization_social', 'rw_organization_admin', ]); }); -test('linkedin oauth callback creates account', function () { - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); +test('connect is forbidden when both linkedin capabilities are disabled', function () { + config(['trypost.platforms.linkedin.enabled' => false]); + config(['trypost.platforms.linkedin-page.enabled' => false]); - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('abc123xyz'); - $socialiteUser->shouldReceive('getNickname')->andReturn(null); - $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 5184000; // 60 days - $socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social']; + $this->actingAs($this->user) + ->get(route('app.social.linkedin.connect')) + ->assertForbidden(); +}); + +test('connect returns the popup callback when the user has no workspace', function () { + // Runs inside the OAuth popup, so it must not redirect away and strand it. + $this->user->update(['current_workspace_id' => null]); + + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.connect')); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); +}); + +test('linkedin callback stores the person and organizations then redirects to the selector', function () { + session(['social_connect_workspace' => $this->workspace->id]); Socialite::shouldReceive('driver') - ->with('linkedin') - ->andReturn(Mockery::mock([ - 'user' => $socialiteUser, - ])); + ->with('linkedin-openid') + ->andReturn(Mockery::mock(['user' => linkedInSocialiteUser()])); Http::fake([ - 'https://api.linkedin.com/v2/me*' => Http::response([ - 'id' => 'abc123xyz', - 'vanityName' => 'johndoe', - 'localizedFirstName' => 'John', - 'localizedLastName' => 'Doe', + config('trypost.platforms.linkedin.api').'/v2/me*' => Http::response(['id' => 'person-123', 'vanityName' => 'johndoe'], 200), + config('trypost.platforms.linkedin.api').'/v2/organizationAcls*' => Http::response([ + 'elements' => [ + ['organization~' => ['id' => 123456, 'localizedName' => 'Test Company', 'vanityName' => 'testcompany']], + ], ], 200), ]); $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + $response->assertRedirect(route('app.social.linkedin.select-identity')); + + expect(session('linkedin_pending.person.id'))->toBe('person-123'); + expect(session('linkedin_pending.person.vanity_name'))->toBe('johndoe'); + expect(session('linkedin_pending.organizations'))->toHaveCount(1); +}); + +test('linkedin callback still redirects to the selector when the member administers no organizations', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + Socialite::shouldReceive('driver') + ->with('linkedin-openid') + ->andReturn(Mockery::mock(['user' => linkedInSocialiteUser()])); + + Http::fake([ + config('trypost.platforms.linkedin.api').'/v2/me*' => Http::response(['vanityName' => 'johndoe'], 200), + config('trypost.platforms.linkedin.api').'/v2/organizationAcls*' => Http::response(['elements' => []], 200), + ]); + + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + + $response->assertRedirect(route('app.social.linkedin.select-identity')); + expect(session('linkedin_pending.organizations'))->toBe([]); +}); + +test('linkedin callback fails with expired session', function () { + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + + $response->assertOk(); + $response->assertViewHas('success', false); + $response->assertViewHas('message', 'Session expired. Please try again.'); +}); + +test('linkedin callback handles oauth errors gracefully', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $mock = Mockery::mock(); + $mock->shouldReceive('user')->andThrow(new Exception('OAuth error')); + + Socialite::shouldReceive('driver')->with('linkedin-openid')->andReturn($mock); + + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + + $response->assertOk(); + $response->assertViewHas('success', false); + $response->assertViewHas('message', 'Error connecting account. Please try again.'); +}); + +test('select-identity screen renders the person and organizations', function () { + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [ + ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null], + ], + ]]); + + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity')); + + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('accounts/LinkedInSelect') + ->where('person.name', 'John Doe') + ->has('organizations', 1) + ); +}); + +test('select-identity hides the personal profile when that capability is disabled', function () { + config(['trypost.platforms.linkedin.enabled' => false]); + config(['trypost.platforms.linkedin-page.enabled' => true]); + + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'w_organization_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => null], + 'organizations' => [ + ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null], + ], + ]]); + + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity')); + + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('accounts/LinkedInSelect') + ->where('person', null) + ->has('organizations', 1) + ); +}); + +test('selecting the person is rejected when the personal profile capability is disabled', function () { + config(['trypost.platforms.linkedin.enabled' => false]); + config(['trypost.platforms.linkedin-page.enabled' => true]); + + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'w_organization_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => null], + 'organizations' => [], + ]]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'person', + ]); + + $response->assertViewHas('success', false); + $this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 'person-123']); +}); + +test('selecting an organization is rejected when company pages are disabled', function () { + config(['trypost.platforms.linkedin.enabled' => true]); + config(['trypost.platforms.linkedin-page.enabled' => false]); + + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [], + ]]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'organization', + 'organization_id' => 123456, + ]); + + $response->assertViewHas('success', false); + $this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 123456]); +}); + +test('select-identity returns the popup callback when the session expired', function () { + // Rendered inside the OAuth popup, so a redirect would strand it — it must + // answer with the self-closing callback view instead. + $response = $this->actingAs($this->user)->get(route('app.social.linkedin.select-identity')); + + $response->assertOk(); + $response->assertViewIs('auth.social-callback'); + $response->assertViewHas('success', false); +}); + +test('selecting the person creates a linkedin account', function () { + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [], + ]]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'person', + ]); + $response->assertOk(); $response->assertViewIs('auth.social-callback'); $response->assertViewHas('success', true); @@ -129,159 +330,212 @@ $this->assertDatabaseHas('social_accounts', [ 'workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn->value, - 'platform_user_id' => 'abc123xyz', + 'platform_user_id' => 'person-123', 'username' => 'johndoe', + 'display_name' => 'John Doe', 'status' => Status::Connected->value, ]); + + expect(session('linkedin_pending'))->toBeNull(); +}); + +test('selecting the person downloads and stores the avatar', function () { + Storage::fake(); + + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'], + 'person' => ['id' => 'person-avatar', 'name' => 'John Doe', 'avatar' => 'https://media.example.com/avatar.jpg', 'vanity_name' => 'johndoe'], + 'organizations' => [], + ]]); + + Http::fake([ + 'https://media.example.com/avatar.jpg' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']), + ]); + + $this->actingAs($this->user)->post(route('app.social.linkedin.select'), ['type' => 'person']); + + // The avatar download (uploadFromUrl) ran and a stored path was persisted. + Http::assertSent(fn ($request) => $request->url() === 'https://media.example.com/avatar.jpg'); + + $account = SocialAccount::where('platform_user_id', 'person-avatar')->first(); + expect($account->getRawOriginal('avatar_url'))->not->toBeNull(); +}); + +test('selecting an organization creates a linkedin-page account with the admin recorded in meta', function () { + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_organization_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [ + ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null], + ], + ]]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'organization', + 'organization_id' => 123456, + ]); + + $response->assertOk(); + $response->assertViewHas('success', true); + + $account = SocialAccount::where('platform', Platform::LinkedInPage->value) + ->where('platform_user_id', 123456) + ->first(); + + expect($account)->not->toBeNull(); + expect($account->display_name)->toBe('Test Company'); + expect($account->username)->toBe('testcompany'); + expect($account->meta['admin_user_id'])->toBe('person-123'); + expect($account->meta['admin_name'])->toBe('John Doe'); +}); + +test('selecting an organization the member does not administer is rejected', function () { + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'w_organization_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [ + ['id' => 111, 'name' => 'My Company', 'vanity_name' => 'myco', 'logo' => null], + ], + ]]); + + // 999 is not in the admin-verified list — a tampered POST must not connect it. + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'organization', + 'organization_id' => 999, + ]); + + $response->assertViewHas('success', false); + $this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 999]); +}); + +test('selecting an organization splits comma-separated approvedScopes before saving', function () { + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + // Socialite returns LinkedIn scopes CSV-joined into a single element. + 'approved_scopes' => ['email,openid,profile,w_organization_social,r_organization_social,rw_organization_admin,w_member_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [ + ['id' => 999888, 'name' => 'Scope Company', 'vanity_name' => 'scopeco', 'logo' => null], + ], + ]]); + + $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'organization', + 'organization_id' => 999888, + ]); + + $account = SocialAccount::where('platform_user_id', 999888)->first(); + expect($account->scopes)->toEqualCanonicalizing([ + 'email', 'openid', 'profile', + 'w_organization_social', 'r_organization_social', + 'rw_organization_admin', 'w_member_social', + ]); }); -test('linkedin callback shows network_taken when the linkedin network is already connected', function () { - config()->set('trypost.self_hosted', false); - - // A LinkedIn Page occupies the shared "linkedin" network for this workspace. - SocialAccount::factory()->linkedinPage()->create([ +test('select rejects an invalid identity type without stranding the popup', function () { + session(['linkedin_pending' => [ 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'existing-linkedin-page', - ]); + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => [], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [], + ]]); - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('new-profile-id'); - $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 5184000; - $socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social']; - - Socialite::shouldReceive('driver') - ->with('linkedin') - ->andReturn(Mockery::mock(['user' => $socialiteUser])); - - Http::fake([ - 'https://api.linkedin.com/v2/me*' => Http::response([ - 'id' => 'new-profile-id', - 'vanityName' => 'johndoe', - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), ['type' => 'bogus']); + // A redirect-back would strand the popup; it must answer with the self-closing callback view. $response->assertOk(); $response->assertViewIs('auth.social-callback'); $response->assertViewHas('success', false); - $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); - - $this->assertDatabaseMissing('social_accounts', [ - 'platform' => Platform::LinkedIn->value, - 'platform_user_id' => 'new-profile-id', - ]); + $this->assertDatabaseMissing('social_accounts', ['platform_user_id' => 'person-123']); }); -test('linkedin oauth callback splits comma-separated approvedScopes before saving', function () { - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('abc123xyz'); - $socialiteUser->shouldReceive('getNickname')->andReturn(null); - $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 5184000; - // LinkedIn returns scopes comma-separated; Socialite's LinkedInProvider - // splits on space (the OAuth 2.0 default), so approvedScopes lands as - // a single-element array with the whole CSV inside. The save path - // must normalize back to individual tokens. - $socialiteUser->approvedScopes = ['email,openid,profile,w_member_social']; - - Socialite::shouldReceive('driver') - ->with('linkedin') - ->andReturn(Mockery::mock(['user' => $socialiteUser])); - - Http::fake([ - 'https://api.linkedin.com/v2/me*' => Http::response([ - 'id' => 'abc123xyz', - 'vanityName' => 'johndoe', - ], 200), +test('select fails with expired session', function () { + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'person', ]); - $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); - - $account = SocialAccount::where('platform_user_id', 'abc123xyz')->first(); - expect($account->scopes)->toEqualCanonicalizing([ - 'email', 'openid', 'profile', 'w_member_social', - ]); -}); - -test('linkedin callback fails with expired session', function () { - // No session data - simulating expired session - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); - $response->assertOk(); $response->assertViewHas('success', false); $response->assertViewHas('message', 'Session expired. Please try again.'); }); -test('user can connect multiple linkedin accounts in self-hosted mode', function () { +test('selecting the person shows network_taken when a linkedin page already occupies the network', function () { + config()->set('trypost.self_hosted', false); + + SocialAccount::factory()->linkedinPage()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'existing-linkedin-page', + ]); + + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'test-access-token', + 'refresh_token' => 'test-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'], + 'person' => ['id' => 'new-person', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [], + ]]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'person', + ]); + + $response->assertOk(); + $response->assertViewHas('success', false); + $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); + + $this->assertDatabaseMissing('social_accounts', [ + 'platform' => Platform::LinkedIn->value, + 'platform_user_id' => 'new-person', + ]); +}); + +test('user can connect multiple linkedin organizations in self-hosted mode', function () { config()->set('trypost.self_hosted', true); - SocialAccount::factory()->linkedin()->create([ + SocialAccount::factory()->linkedinPage()->create([ 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'abc123xyz', + 'platform_user_id' => '123456', ]); - session([ - 'social_connect_workspace' => $this->workspace->id, + session(['linkedin_pending' => [ + 'workspace_id' => $this->workspace->id, + 'token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 5184000, + 'approved_scopes' => ['openid', 'profile', 'email', 'w_organization_social'], + 'person' => ['id' => 'person-123', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], + 'organizations' => [ + ['id' => 789012, 'name' => 'Another Company', 'vanity_name' => 'anothercompany', 'logo' => null], + ], + ]]); + + $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ + 'type' => 'organization', + 'organization_id' => 789012, ]); - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('xyz789abc'); - $socialiteUser->shouldReceive('getNickname')->andReturn(null); - $socialiteUser->shouldReceive('getName')->andReturn('Jane Doe'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'new-access-token'; - $socialiteUser->refreshToken = 'new-refresh-token'; - $socialiteUser->expiresIn = 5184000; - $socialiteUser->approvedScopes = ['openid', 'profile', 'email', 'w_member_social']; - - Socialite::shouldReceive('driver') - ->with('linkedin') - ->andReturn(Mockery::mock([ - 'user' => $socialiteUser, - ])); - - Http::fake([ - 'https://api.linkedin.com/v2/me*' => Http::response([ - 'vanityName' => 'janedoe', - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); - $response->assertOk(); $response->assertViewHas('success', true); - expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedIn)->count())->toBe(2); -}); - -test('linkedin callback handles oauth errors gracefully', function () { - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $mock = Mockery::mock(); - $mock->shouldReceive('user')->andThrow(new Exception('OAuth error')); - - Socialite::shouldReceive('driver') - ->with('linkedin') - ->andReturn($mock); - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin.callback')); - - $response->assertOk(); - $response->assertViewHas('success', false); - $response->assertViewHas('message', 'Error connecting account. Please try again.'); + expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2); }); diff --git a/tests/Feature/Social/LinkedInPageControllerTest.php b/tests/Feature/Social/LinkedInPageControllerTest.php deleted file mode 100644 index ac79d246..00000000 --- a/tests/Feature/Social/LinkedInPageControllerTest.php +++ /dev/null @@ -1,311 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); - $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); -}); - -test('linkedin page connect redirects to oauth provider', function () { - $driverMock = Mockery::mock(); - $driverMock->shouldReceive('scopes')->andReturnSelf(); - $driverMock->shouldReceive('with')->andReturnSelf(); - $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ - 'getTargetUrl' => 'https://www.linkedin.com/oauth/v2/authorization?test=1', - ])); - - Socialite::shouldReceive('driver') - ->with('linkedin-openid') - ->andReturn($driverMock); - - $response = $this->actingAs($this->user) - ->withHeader('X-Inertia', 'true') - ->get(route('app.social.linkedin-page.connect')); - - $response->assertStatus(409); // Inertia::location returns 409 with X-Inertia header - - expect(session('social_connect_workspace'))->toBe($this->workspace->id); -}); - -test('linkedin page oauth callback fetches organizations', function () { - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('user123'); - $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 5184000; - - $socialiteMock = Mockery::mock(); - $socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('with')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('user')->andReturn($socialiteUser); - - Socialite::shouldReceive('driver') - ->with('linkedin-openid') - ->andReturn($socialiteMock); - - Http::fake([ - 'https://api.linkedin.com/v2/organizationAcls*' => Http::response([ - 'elements' => [ - [ - 'organization~' => [ - 'id' => 123456, - 'localizedName' => 'Test Company', - 'vanityName' => 'testcompany', - ], - ], - ], - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback')); - - $response->assertRedirect(route('app.social.linkedin-page.select-page')); - - expect(session('linkedin_page_pending'))->not->toBeNull(); - expect(session('linkedin_page_pending.organizations'))->toHaveCount(1); -}); - -test('linkedin page callback fails when user has no organizations', function () { - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('user123'); - $socialiteUser->shouldReceive('getName')->andReturn('John Doe'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 5184000; - - $socialiteMock = Mockery::mock(); - $socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('with')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('user')->andReturn($socialiteUser); - - Socialite::shouldReceive('driver') - ->with('linkedin-openid') - ->andReturn($socialiteMock); - - Http::fake([ - 'https://api.linkedin.com/v2/organizationAcls*' => Http::response([ - 'elements' => [], - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback')); - - $response->assertOk(); - $response->assertViewHas('success', false); - $response->assertViewHas('message', 'You are not an administrator of any LinkedIn page.'); -}); - -test('linkedin page callback fails with expired session', function () { - // No session data - simulating expired session - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback')); - - $response->assertOk(); - $response->assertViewHas('success', false); - $response->assertViewHas('message', 'Session expired. Please try again.'); -}); - -test('linkedin page select creates account', function () { - session([ - 'linkedin_page_pending' => [ - 'workspace_id' => $this->workspace->id, - 'user_id' => 'user123', - 'name' => 'John Doe', - 'avatar' => null, - 'token' => 'test-access-token', - 'refresh_token' => 'test-refresh-token', - 'expires_in' => 5184000, - 'organizations' => [ - ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null], - ], - ], - ]); - - $response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [ - 'organization_id' => 123456, - 'organization_name' => 'Test Company', - 'organization_vanity_name' => 'testcompany', - 'organization_logo' => null, - ]); - - $response->assertOk(); - $response->assertViewIs('auth.social-callback'); - $response->assertViewHas('success', true); - - $this->assertDatabaseHas('social_accounts', [ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::LinkedInPage->value, - 'platform_user_id' => 123456, - 'username' => 'testcompany', - 'display_name' => 'Test Company', - 'status' => Status::Connected->value, - ]); -}); - -test('linkedin page select splits comma-separated approvedScopes before saving', function () { - session([ - 'linkedin_page_pending' => [ - 'workspace_id' => $this->workspace->id, - 'user_id' => 'user123', - 'name' => 'John Doe', - 'avatar' => null, - 'token' => 'test-access-token', - 'refresh_token' => 'test-refresh-token', - 'expires_in' => 5184000, - // Simulates what Socialite returns for LinkedIn (CSV-joined into - // a single array element because the provider splits on space). - 'approved_scopes' => ['email,openid,profile,w_organization_social,r_organization_social,rw_organization_admin,w_member_social'], - 'organizations' => [ - ['id' => 999888, 'name' => 'Scope Company', 'vanity_name' => 'scopeco', 'logo' => null], - ], - ], - ]); - - $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [ - 'organization_id' => 999888, - 'organization_name' => 'Scope Company', - 'organization_vanity_name' => 'scopeco', - 'organization_logo' => null, - ]); - - $account = SocialAccount::where('platform_user_id', 999888)->first(); - expect($account->scopes)->toEqualCanonicalizing([ - 'email', 'openid', 'profile', - 'w_organization_social', 'r_organization_social', - 'rw_organization_admin', 'w_member_social', - ]); -}); - -test('linkedin page select fails with expired session', function () { - // No session data - - $response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [ - 'organization_id' => 123456, - 'organization_name' => 'Test Company', - 'organization_vanity_name' => 'testcompany', - ]); - - $response->assertOk(); - $response->assertViewHas('success', false); - $response->assertViewHas('message', 'Session expired. Please try again.'); -}); - -test('user can connect multiple linkedin pages in self-hosted mode', function () { - config()->set('trypost.self_hosted', true); - - SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => '123456', - ]); - - session([ - 'linkedin_page_pending' => [ - 'workspace_id' => $this->workspace->id, - 'user_id' => 'user123', - 'name' => 'John Doe', - 'avatar' => null, - 'token' => 'new-access-token', - 'refresh_token' => 'new-refresh-token', - 'expires_in' => 5184000, - 'organizations' => [ - ['id' => 789012, 'name' => 'Another Company', 'vanity_name' => 'anothercompany', 'logo' => null], - ], - ], - ]); - - $response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [ - 'organization_id' => 789012, - 'organization_name' => 'Another Company', - 'organization_vanity_name' => 'anothercompany', - 'organization_logo' => null, - ]); - - $response->assertOk(); - $response->assertViewHas('success', true); - - expect($this->workspace->socialAccounts()->where('platform', Platform::LinkedInPage)->count())->toBe(2); -}); - -test('linkedin page callback handles oauth errors gracefully', function () { - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $socialiteMock = Mockery::mock(); - $socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('with')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('user')->andThrow(new Exception('OAuth error')); - - Socialite::shouldReceive('driver') - ->with('linkedin-openid') - ->andReturn($socialiteMock); - - $response = $this->actingAs($this->user)->get(route('app.social.linkedin-page.callback')); - - $response->assertOk(); - $response->assertViewHas('success', false); - $response->assertViewHas('message', 'Error connecting account. Please try again.'); -}); - -test('linkedin page select shows network_taken when a linkedin account is already connected', function () { - config()->set('trypost.self_hosted', false); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::LinkedIn, - 'platform_user_id' => 'existing-linkedin', - ]); - - session([ - 'linkedin_page_pending' => [ - 'workspace_id' => $this->workspace->id, - 'user_id' => 'user123', - 'name' => 'John Doe', - 'avatar' => null, - 'token' => 'test-access-token', - 'refresh_token' => 'test-refresh-token', - 'expires_in' => 5184000, - 'organizations' => [ - ['id' => 123456, 'name' => 'Test Company', 'vanity_name' => 'testcompany', 'logo' => null], - ], - ], - ]); - - $response = $this->actingAs($this->user)->post(route('app.social.linkedin-page.select'), [ - 'organization_id' => 123456, - 'organization_name' => 'Test Company', - 'organization_vanity_name' => 'testcompany', - 'organization_logo' => null, - ]); - - $response->assertOk(); - $response->assertViewIs('auth.social-callback'); - $response->assertViewHas('success', false); - $response->assertViewHas('message', __('accounts.popup_callback.network_taken')); - - expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::LinkedIn->value, Platform::LinkedInPage->value])->count())->toBe(1); -}); diff --git a/tests/Feature/SocialControllerTest.php b/tests/Feature/SocialControllerTest.php index a0ed0a12..9a99e62a 100644 --- a/tests/Feature/SocialControllerTest.php +++ b/tests/Feature/SocialControllerTest.php @@ -43,6 +43,56 @@ ); }); +test('accounts index offers a single linkedin card and no standalone linkedin page card', function () { + $response = $this->actingAs($this->user)->get(route('app.accounts')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('accounts/Index', false) + ->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::LinkedIn->value) + && ! collect($platforms)->contains('value', Platform::LinkedInPage->value) + ) + ); +}); + +test('the linkedin card still shows when only company pages are enabled', function () { + config(['trypost.platforms.linkedin.enabled' => false]); + config(['trypost.platforms.linkedin-page.enabled' => true]); + + $response = $this->actingAs($this->user)->get(route('app.accounts')); + + $response->assertInertia(fn ($page) => $page + ->component('accounts/Index', false) + ->where('platforms', fn ($platforms) => collect($platforms)->contains('value', Platform::LinkedIn->value)) + ); +}); + +test('the linkedin card disappears only when both capabilities are disabled', function () { + config(['trypost.platforms.linkedin.enabled' => false]); + config(['trypost.platforms.linkedin-page.enabled' => false]); + + $response = $this->actingAs($this->user)->get(route('app.accounts')); + + $response->assertInertia(fn ($page) => $page + ->component('accounts/Index', false) + ->where('platforms', fn ($platforms) => ! collect($platforms)->contains('value', Platform::LinkedIn->value)) + ); +}); + +test('a connected linkedin page account is still returned so it surfaces under the linkedin card', function () { + SocialAccount::factory()->linkedinPage()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $response = $this->actingAs($this->user)->get(route('app.accounts')); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('accounts/Index', false) + ->where('connectedAccounts.0.platform', Platform::LinkedInPage->value) + ); +}); + test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () { config(['trypost.self_hosted' => false]); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 5d61e159..e66d7f6d 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -10,7 +10,6 @@ expect(ContentType::InstagramReel->label())->toBe('Reel'); expect(ContentType::InstagramStory->label())->toBe('Story'); expect(ContentType::LinkedInPost->label())->toBe('Post'); - expect(ContentType::LinkedInCarousel->label())->toBe('Carousel'); expect(ContentType::YouTubeShort->label())->toBe('Short'); expect(ContentType::XPost->label())->toBe('Post'); expect(ContentType::TikTokVideo->label())->toBe('Video'); @@ -60,7 +59,7 @@ test('content type has correct max media count', function () { expect(ContentType::InstagramFeed->maxMediaCount())->toBe(10); expect(ContentType::InstagramReel->maxMediaCount())->toBe(1); - expect(ContentType::LinkedInCarousel->maxMediaCount())->toBe(20); + expect(ContentType::LinkedInPost->maxMediaCount())->toBe(20); expect(ContentType::XPost->maxMediaCount())->toBe(4); expect(ContentType::PinterestCarousel->maxMediaCount())->toBe(5); expect(ContentType::BlueskyPost->maxMediaCount())->toBe(4); @@ -71,7 +70,7 @@ expect(ContentType::InstagramReel->supportsVideo())->toBeTrue(); expect(ContentType::TikTokVideo->supportsVideo())->toBeTrue(); expect(ContentType::YouTubeShort->supportsVideo())->toBeTrue(); - expect(ContentType::LinkedInCarousel->supportsVideo())->toBeFalse(); + expect(ContentType::LinkedInPost->supportsVideo())->toBeTrue(); expect(ContentType::PinterestPin->supportsVideo())->toBeFalse(); }); @@ -151,28 +150,25 @@ expect(ContentType::MastodonPost->supportsVideo())->toBeTrue(); }); -test('linkedin document content types are pdf-only and single-file', function () { - foreach ([ContentType::LinkedInDocument, ContentType::LinkedInPageDocument] as $type) { - expect($type->label())->toBe('Document'); +test('linkedin post content types accept images, videos and documents but never mixed', function () { + foreach ([ContentType::LinkedInPost, ContentType::LinkedInPagePost] as $type) { + expect($type->supportsImage())->toBeTrue(); + expect($type->supportsVideo())->toBeTrue(); expect($type->supportsDocument())->toBeTrue(); - expect($type->supportsImage())->toBeFalse(); - expect($type->supportsVideo())->toBeFalse(); - expect($type->requiresMedia())->toBeTrue(); - expect($type->maxMediaCount())->toBe(1); + expect($type->supportsMixedMedia())->toBeFalse(); + expect($type->requiresMedia())->toBeFalse(); + expect($type->maxMediaCount())->toBe(20); } - - expect(ContentType::LinkedInDocument->platform())->toBe(Platform::LinkedIn); - expect(ContentType::LinkedInPageDocument->platform())->toBe(Platform::LinkedInPage); }); -test('only linkedin document content types support documents', function () { - expect(ContentType::LinkedInPost->supportsDocument())->toBeFalse(); - expect(ContentType::LinkedInCarousel->supportsDocument())->toBeFalse(); +test('only linkedin post content types support documents', function () { + expect(ContentType::LinkedInPost->supportsDocument())->toBeTrue(); + expect(ContentType::LinkedInPagePost->supportsDocument())->toBeTrue(); expect(ContentType::InstagramFeed->supportsDocument())->toBeFalse(); expect(ContentType::XPost->supportsDocument())->toBeFalse(); }); -test('linkedin document content types are exposed for their platform', function () { - expect(ContentType::forPlatform(Platform::LinkedIn))->toContain(ContentType::LinkedInDocument); - expect(ContentType::forPlatform(Platform::LinkedInPage))->toContain(ContentType::LinkedInPageDocument); +test('linkedin exposes a single post content type per account kind', function () { + expect(ContentType::forPlatform(Platform::LinkedIn))->toHaveCount(1)->toContain(ContentType::LinkedInPost); + expect(ContentType::forPlatform(Platform::LinkedInPage))->toHaveCount(1)->toContain(ContentType::LinkedInPagePost); }); diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 65915963..f0f496a6 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -45,7 +45,7 @@ }); test('platform has correct max images', function () { - expect(Platform::LinkedIn->maxImages())->toBe(1); + expect(Platform::LinkedIn->maxImages())->toBe(20); expect(Platform::X->maxImages())->toBe(4); expect(Platform::TikTok->maxImages())->toBe(0); expect(Platform::YouTube->maxImages())->toBe(0); @@ -110,3 +110,25 @@ expect($enabled)->not->toContain(Platform::LinkedIn); }); + +test('only linkedin pages are not directly connectable', function () { + expect(Platform::LinkedInPage->isConnectable())->toBeFalse(); + expect(Platform::LinkedIn->isConnectable())->toBeTrue(); + expect(Platform::InstagramFacebook->isConnectable())->toBeTrue(); + expect(Platform::Instagram->isConnectable())->toBeTrue(); +}); + +test('the linkedin card is connectable while either capability is enabled', function () { + config(['trypost.platforms.linkedin.enabled' => true, 'trypost.platforms.linkedin-page.enabled' => false]); + expect(Platform::LinkedIn->isConnectable())->toBeTrue(); + + config(['trypost.platforms.linkedin.enabled' => false, 'trypost.platforms.linkedin-page.enabled' => true]); + expect(Platform::LinkedIn->isConnectable())->toBeTrue(); + + config(['trypost.platforms.linkedin.enabled' => false, 'trypost.platforms.linkedin-page.enabled' => false]); + expect(Platform::LinkedIn->isConnectable())->toBeFalse(); + + // A company page never gets its own card regardless of toggles. + config(['trypost.platforms.linkedin-page.enabled' => true]); + expect(Platform::LinkedInPage->isConnectable())->toBeFalse(); +}); diff --git a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php index a557d163..011769f3 100644 --- a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php +++ b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php @@ -136,71 +136,90 @@ function runMediaRule(string $contentType, array $media): array expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]); }); -test('linkedin document accepts a pdf', function () { +test('linkedin post accepts a pdf on its own', function () { $media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']]; - expect(runMediaRule(ContentType::LinkedInDocument->value, $media))->toBe([]); - expect(runMediaRule(ContentType::LinkedInPageDocument->value, $media))->toBe([]); + expect(runMediaRule(ContentType::LinkedInPost->value, $media))->toBe([]); + expect(runMediaRule(ContentType::LinkedInPagePost->value, $media))->toBe([]); }); -test('linkedin document detects a pdf from mime when type field is missing', function () { +test('linkedin post detects a pdf from mime when type field is missing', function () { $media = [['mime_type' => 'application/pdf']]; - expect(runMediaRule(ContentType::LinkedInDocument->value, $media))->toBe([]); + expect(runMediaRule(ContentType::LinkedInPost->value, $media))->toBe([]); }); -test('linkedin document rejects an image', function () { - $media = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']]; - - $errors = runMediaRule(ContentType::LinkedInDocument->value, $media); - - expect($errors)->toHaveCount(1); - expect($errors[0])->toContain('does not support images'); -}); - -test('a pdf is rejected on content types that are not documents', function () { - $media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']]; +test('a pdf must be the only attachment on linkedin', function () { + $media = [ + ['type' => MediaType::Document->value, 'mime_type' => 'application/pdf'], + ['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'], + ]; $errors = runMediaRule(ContentType::LinkedInPost->value, $media); expect($errors)->toHaveCount(1); - expect($errors[0])->toContain('does not support PDF documents'); + expect($errors[0])->toContain('must be the only attachment'); +}); - expect(runMediaRule(ContentType::LinkedInCarousel->value, $media)[0])->toContain('does not support PDF documents'); +test('linkedin rejects mixing an image and a video', function () { + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + $errors = runMediaRule(ContentType::LinkedInPost->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain("can't combine an image and a video"); +}); + +test('a pdf is rejected on content types that do not support documents', function () { + $media = [['type' => MediaType::Document->value, 'mime_type' => 'application/pdf']]; + + $errors = runMediaRule(ContentType::XPost->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain('does not support PDF documents'); }); test('falls back to stored media when the request omits the media key', function () { + // A PDF fallback on a document-capable type passes. $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 { + ->validate('platforms.0.content_type', ContentType::LinkedInPost->value, function (string $message) use (&$errors): void { $errors[] = $message; }); expect($errors)->toBe([]); - $imageErrors = []; - (new ContentTypeCompatibleWithMedia([['type' => 'image', 'mime_type' => 'image/jpeg']])) + // The same PDF fallback on X (no document support) is rejected — proving the fallback is used. + $xErrors = []; + (new ContentTypeCompatibleWithMedia([['type' => 'document', 'mime_type' => 'application/pdf']])) ->setData([]) - ->validate('platforms.0.content_type', ContentType::LinkedInDocument->value, function (string $message) use (&$imageErrors): void { - $imageErrors[] = $message; + ->validate('platforms.0.content_type', ContentType::XPost->value, function (string $message) use (&$xErrors): void { + $xErrors[] = $message; }); - expect($imageErrors)->toHaveCount(1); - expect($imageErrors[0])->toContain('does not support images'); + expect($xErrors)->toHaveCount(1); + expect($xErrors[0])->toContain('does not support PDF documents'); }); test('request media takes precedence over the stored fallback', function () { - // Fallback is a PDF, but the request explicitly carries an image instead. + // Fallback is a lone PDF (would pass), but the request carries a PDF + image, + // which must be rejected — proving the request media is used over the fallback. $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 { + ->setData(['media' => [ + ['type' => 'document', 'mime_type' => 'application/pdf'], + ['type' => 'image', 'mime_type' => 'image/jpeg'], + ]]) + ->validate('platforms.0.content_type', ContentType::LinkedInPost->value, function (string $message) use (&$errors): void { $errors[] = $message; }); expect($errors)->toHaveCount(1); - expect($errors[0])->toContain('does not support images'); + expect($errors[0])->toContain('must be the only attachment'); }); test('does nothing for invalid content type values', function () { diff --git a/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php b/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php index 8a0fce74..c3fe5352 100644 --- a/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php +++ b/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php @@ -35,7 +35,6 @@ function runMatchesPlatformRule(string $contentType, ?string $accountId, array $ ]); expect(runMatchesPlatformRule(ContentType::LinkedInPost->value, $linkedin->id))->toBe([]); - expect(runMatchesPlatformRule(ContentType::LinkedInCarousel->value, $linkedin->id))->toBe([]); }); test('fails when content_type belongs to a different platform', function () { diff --git a/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php b/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php index cb4f22db..0c0396c0 100644 --- a/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php +++ b/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php @@ -42,7 +42,6 @@ function runMatchesPostPlatformRule(string $contentType, ?string $postPlatformId ]); expect(runMatchesPostPlatformRule(ContentType::LinkedInPost->value, $postPlatform->id))->toBe([]); - expect(runMatchesPostPlatformRule(ContentType::LinkedInCarousel->value, $postPlatform->id))->toBe([]); }); test('fails when content_type belongs to a different platform than the post_platform', function () {