refactor(linkedin): infer post format from media + unify account connection
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types. Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
This commit is contained in:
parent
051c797cc1
commit
d84666360a
50 changed files with 1438 additions and 1902 deletions
|
|
@ -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)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
16
app/Enums/SocialAccount/LinkedInIdentityType.php
Normal file
16
app/Enums/SocialAccount/LinkedInIdentityType.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\SocialAccount;
|
||||
|
||||
/**
|
||||
* Which LinkedIn identity a member posts as after the unified connect flow:
|
||||
* their personal profile (creates a `linkedin` account) or a company page
|
||||
* they administer (creates a `linkedin-page` account).
|
||||
*/
|
||||
enum LinkedInIdentityType: string
|
||||
{
|
||||
case Person = 'person';
|
||||
case Organization = 'organization';
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ public function allowedMediaTypes(): array
|
|||
public function maxImages(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::LinkedIn, self::LinkedInPage => 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.
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $pending
|
||||
* @return array<string, mixed>|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<string, mixed> $pending
|
||||
* @param array<string, mixed> $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<int, string>
|
||||
*/
|
||||
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<int, string> $approvedScopes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<int, array{id: mixed, name: string, vanity_name: ?string, logo: ?string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,273 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
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\View\View;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class LinkedInPageController extends SocialController
|
||||
{
|
||||
protected string $driver = 'linkedin-openid';
|
||||
|
||||
protected SocialPlatform $platform = SocialPlatform::LinkedInPage;
|
||||
|
||||
public function connect(Request $request): SymfonyResponse|RedirectResponse
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class LinkedInTokenSynchronizer
|
||||
{
|
||||
/**
|
||||
* Sync tokens from LinkedIn personal to LinkedIn Page (or vice versa).
|
||||
* When one account gets a new token, update every linked account in the
|
||||
* same workspace so multiple pages sharing the same admin keep working.
|
||||
*/
|
||||
public function syncTokens(SocialAccount $sourceAccount): void
|
||||
{
|
||||
$workspace = $sourceAccount->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<int, SocialAccount>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* LinkedIn no longer has per-format content types — the publish format is
|
||||
* inferred from the attached media. Collapse the old carousel/document
|
||||
* variants back onto the single post type for each account kind.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('post_platforms')
|
||||
->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.
|
||||
}
|
||||
};
|
||||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
<DiscordSettings
|
||||
v-else-if="channel.platform === Platform.Discord"
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ const getPlatformDescription = (platform: string): string =>
|
|||
|
||||
// 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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
disabled?: boolean;
|
||||
previewOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
meta: () => ({}),
|
||||
disabled: false,
|
||||
previewOnly: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:contentType': [value: string];
|
||||
'update:meta': [value: Record<string, any>];
|
||||
}>();
|
||||
|
||||
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));
|
||||
</script>
|
||||
|
|
@ -94,23 +87,14 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.variant_label') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="variant in variants"
|
||||
:key="variant.value"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="contentType === variant.value
|
||||
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
|
||||
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
|
||||
:disabled="disabled"
|
||||
@click="pickVariant(variant.value)"
|
||||
>
|
||||
{{ $t(variant.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="hasPdf" class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.document_title') }}</p>
|
||||
<Input
|
||||
v-model="documentTitle"
|
||||
type="text"
|
||||
:placeholder="$t('posts.form.linkedin.document_title_placeholder')"
|
||||
:disabled="disabled || previewOnly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ export const getMediaValidationWarning = (
|
|||
if (! rules.acceptDocuments && documents.length > 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: {} };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,39 +60,18 @@ const CONTENT_TYPE_RULES: Record<string, MediaRules> = {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
|
|||
'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'],
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ export const PLATFORM_VARIANTS: Record<string, string[]> = {
|
|||
[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) });
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { IconBuilding, IconCheck } from '@tabler/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import PopupLayout from '@/layouts/PopupLayout.vue';
|
||||
import { select as selectLinkedInPage } from '@/routes/app/social/linkedin-page';
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
vanity_name: string | null;
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
organizations: Organization[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const formRef = ref<HTMLFormElement | null>(null);
|
||||
const selectedOrg = ref<Organization | null>(null);
|
||||
|
||||
const handleSelectPage = (org: Organization) => {
|
||||
selectedOrg.value = org;
|
||||
// Submit via regular form to get Blade response
|
||||
setTimeout(() => formRef.value?.submit(), 0);
|
||||
};
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopupLayout :title="$t('accounts.linkedin.title')">
|
||||
<!-- Hidden form for regular POST submission -->
|
||||
<form ref="formRef" :action="selectLinkedInPage.url()" method="POST" class="hidden">
|
||||
<input type="hidden" name="_token" :value="csrfToken" />
|
||||
<input type="hidden" name="organization_id" :value="selectedOrg?.id" />
|
||||
<input type="hidden" name="organization_name" :value="selectedOrg?.name" />
|
||||
<input type="hidden" name="organization_vanity_name" :value="selectedOrg?.vanity_name ?? ''" />
|
||||
<input type="hidden" name="organization_logo" :value="selectedOrg?.logo ?? ''" />
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.linkedin.title') }}</h1>
|
||||
<p class="text-sm text-muted-foreground">{{ $t('accounts.linkedin.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="error" variant="destructive">
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div v-if="organizations.length === 0 && !error" class="text-center py-12">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<IconBuilding class="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.linkedin.no_pages') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('accounts.linkedin.no_pages_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-3">
|
||||
<button
|
||||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
@click="handleSelectPage(org)"
|
||||
class="group relative overflow-hidden rounded-lg border bg-card p-4 text-left transition-all hover:border-primary hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar class="h-12 w-12 rounded-lg">
|
||||
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
|
||||
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<IconBuilding class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold truncate group-hover:text-primary transition-colors">
|
||||
{{ org.name }}
|
||||
</h3>
|
||||
<p v-if="org.vanity_name" class="text-sm text-muted-foreground truncate">
|
||||
linkedin.com/company/{{ org.vanity_name }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground">{{ $t('accounts.linkedin.page_label') }}</p>
|
||||
</div>
|
||||
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
<IconCheck class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupLayout>
|
||||
</template>
|
||||
186
resources/js/pages/accounts/LinkedInSelect.vue
Normal file
186
resources/js/pages/accounts/LinkedInSelect.vue
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<script setup lang="ts">
|
||||
import { IconBuilding, IconExternalLink, IconUser } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PopupLayout from '@/layouts/PopupLayout.vue';
|
||||
import { select as selectLinkedIn } from '@/routes/app/social/linkedin';
|
||||
|
||||
interface Person {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
vanity_name: string | null;
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
vanity_name: string | null;
|
||||
logo: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
person: Person | null;
|
||||
organizations: Organization[];
|
||||
}>();
|
||||
|
||||
const isEmpty = computed(
|
||||
() => !props.person && props.organizations.length === 0,
|
||||
);
|
||||
|
||||
const formRef = ref<HTMLFormElement | null>(null);
|
||||
const selectedType = ref<'person' | 'organization'>('person');
|
||||
const selectedOrg = ref<Organization | null>(null);
|
||||
|
||||
const csrfToken =
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') ?? '';
|
||||
|
||||
const submit = () => {
|
||||
setTimeout(() => formRef.value?.submit(), 0);
|
||||
};
|
||||
|
||||
const choosePerson = () => {
|
||||
selectedType.value = 'person';
|
||||
selectedOrg.value = null;
|
||||
submit();
|
||||
};
|
||||
|
||||
const chooseOrganization = (org: Organization) => {
|
||||
selectedType.value = 'organization';
|
||||
selectedOrg.value = org;
|
||||
submit();
|
||||
};
|
||||
|
||||
const openExternal = (url: string | null) => {
|
||||
if (url) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
};
|
||||
|
||||
const personUrl = (vanity: string | null): string | null =>
|
||||
vanity ? `https://www.linkedin.com/in/${vanity}` : null;
|
||||
|
||||
const organizationUrl = (vanity: string | null): string | null =>
|
||||
vanity ? `https://www.linkedin.com/company/${vanity}` : null;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopupLayout :title="$t('accounts.linkedin.select_title')">
|
||||
<form ref="formRef" :action="selectLinkedIn.url()" method="POST" class="hidden">
|
||||
<input type="hidden" name="_token" :value="csrfToken" />
|
||||
<input type="hidden" name="type" :value="selectedType" />
|
||||
<input type="hidden" name="organization_id" :value="selectedOrg?.id ?? ''" />
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight">
|
||||
{{ $t('accounts.linkedin.select_title') }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('accounts.linkedin.select_subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEmpty" class="py-12 text-center" dusk="linkedin-identity-empty">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<IconBuilding class="h-7 w-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.linkedin.no_pages') }}</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{{ $t('accounts.linkedin.no_pages_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-3">
|
||||
<div
|
||||
v-if="person"
|
||||
class="flex items-center gap-4 rounded-lg border bg-card p-4"
|
||||
dusk="linkedin-identity-person"
|
||||
>
|
||||
<Avatar class="h-12 w-12 rounded-lg">
|
||||
<AvatarImage v-if="person.avatar" :src="person.avatar" class="object-cover" />
|
||||
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<IconUser class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="truncate font-semibold">{{ person.name }}</h3>
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ $t('accounts.linkedin.person_tag') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="person.vanity_name" class="truncate text-sm text-muted-foreground">
|
||||
linkedin.com/in/{{ person.vanity_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="personUrl(person.vanity_name)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="openExternal(personUrl(person.vanity_name))"
|
||||
>
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
{{ $t('accounts.linkedin.view') }}
|
||||
</Button>
|
||||
<Button size="sm" dusk="choose-person" @click="choosePerson">
|
||||
{{ $t('accounts.linkedin.choose') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
class="flex items-center gap-4 rounded-lg border bg-card p-4"
|
||||
dusk="linkedin-identity-organization"
|
||||
>
|
||||
<Avatar class="h-12 w-12 rounded-lg">
|
||||
<AvatarImage v-if="org.logo" :src="org.logo" class="object-cover" />
|
||||
<AvatarFallback class="rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<IconBuilding class="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="truncate font-semibold">{{ org.name }}</h3>
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{{ $t('accounts.linkedin.organization_tag') }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="org.vanity_name" class="truncate text-sm text-muted-foreground">
|
||||
linkedin.com/company/{{ org.vanity_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
v-if="organizationUrl(org.vanity_name)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="openExternal(organizationUrl(org.vanity_name))"
|
||||
>
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
{{ $t('accounts.linkedin.view') }}
|
||||
</Button>
|
||||
<Button size="sm" dusk="choose-organization" @click="chooseOrganization(org)">
|
||||
{{ $t('accounts.linkedin.choose') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupLayout>
|
||||
</template>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
],
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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' => []],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => [[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Models\PostPlatform;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Load the data migration as an instance so its up() can run against rows that
|
||||
* still carry the retired per-format content types.
|
||||
*/
|
||||
function collapseLinkedInContentTypesMigration(): object
|
||||
{
|
||||
return require database_path('migrations/2026_06_24_220651_collapse_linkedin_content_types.php');
|
||||
}
|
||||
|
||||
test('the migration collapses retired linkedin profile content types onto linkedin_post', function () {
|
||||
$platform = PostPlatform::factory()->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);
|
||||
});
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\LinkedInPublisher;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,270 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\LinkedInTokenSynchronizer;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
|
|
@ -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<int, string>
|
||||
*/
|
||||
$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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,311 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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);
|
||||
});
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue