2026-01-18 18:56:51 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
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\User;
|
|
|
|
|
use App\Models\Workspace;
|
|
|
|
|
use App\Services\Social\LinkedInPagePublisher;
|
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->linkedinPage()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform_user_id' => '123456',
|
|
|
|
|
'username' => 'testcompany',
|
|
|
|
|
'token_expires_at' => now()->addDays(60),
|
|
|
|
|
'meta' => [
|
|
|
|
|
'organization_id' => '123456',
|
|
|
|
|
'admin_user_id' => 'user123',
|
|
|
|
|
'admin_name' => 'John Doe',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Hello from our LinkedIn Page!',
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'platform' => Platform::LinkedInPage,
|
|
|
|
|
'content_type' => ContentType::LinkedInPagePost,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher = new LinkedInPagePublisher;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher can publish text-only post', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result)->toHaveKey('id');
|
|
|
|
|
expect($result)->toHaveKey('url');
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:1234567890');
|
2026-08-11 15:36:14 +00:00
|
|
|
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:1234567890');
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), '/rest/posts')
|
|
|
|
|
&& $request['author'] === 'urn:li:organization:123456'
|
|
|
|
|
&& $request['commentary'] === 'Hello from our LinkedIn Page!'
|
|
|
|
|
&& $request['visibility'] === 'PUBLIC';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher uses organization urn', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request['author'] === 'urn:li:organization:123456';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher throws exception when organization id missing', function () {
|
refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 13:17:02 +00:00
|
|
|
Http::fake();
|
2026-01-18 18:56:51 +00:00
|
|
|
$this->socialAccount->update(['meta' => []]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'LinkedIn Page organization ID not configured');
|
refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 13:17:02 +00:00
|
|
|
|
|
|
|
|
// Fail-fast: the missing org id must abort before any LinkedIn request.
|
|
|
|
|
Http::assertNothingSent();
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher uses correct headers', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request->hasHeader('Authorization')
|
|
|
|
|
&& $request->hasHeader('X-Restli-Protocol-Version')
|
|
|
|
|
&& $request->hasHeader('LinkedIn-Version')
|
|
|
|
|
&& str_starts_with($request->header('Authorization')[0], 'Bearer ');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher throws exception on api error', function () {
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response([
|
2026-01-18 18:56:51 +00:00
|
|
|
'message' => 'Invalid request',
|
|
|
|
|
'status' => 400,
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-26 13:48:20 +00:00
|
|
|
test('linkedin page publisher throws token expired exception on auth error after retry', function () {
|
2026-01-18 18:56:51 +00:00
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response([
|
2026-01-18 18:56:51 +00:00
|
|
|
'code' => 'EXPIRED_ACCESS_TOKEN',
|
|
|
|
|
'message' => 'The token used in the request has expired',
|
|
|
|
|
], 401),
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
2026-01-26 13:48:20 +00:00
|
|
|
'error' => 'invalid_grant',
|
|
|
|
|
'error_description' => 'The refresh token is invalid',
|
|
|
|
|
], 400),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher refreshes token when expired', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
2026-01-18 18:56:51 +00:00
|
|
|
'access_token' => 'new-access-token',
|
|
|
|
|
'refresh_token' => 'new-refresh-token',
|
|
|
|
|
'expires_in' => 5184000,
|
|
|
|
|
], 200),
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return str_contains($request->url(), 'oauth/v2/accessToken');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$this->socialAccount->refresh();
|
|
|
|
|
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('linkedin page publisher throws exception when no refresh token available', function () {
|
|
|
|
|
$this->socialAccount->update([
|
|
|
|
|
'token_expires_at' => now()->subHour(),
|
|
|
|
|
'refresh_token' => null,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'No refresh token available for LinkedIn Page account');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-12 21:56:52 +00:00
|
|
|
test('linkedin page publisher throws TokenExpiredException when refresh_token is rejected', function () {
|
|
|
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken' => Http::response([
|
2026-05-12 21:56:52 +00:00
|
|
|
'error' => 'invalid_grant',
|
|
|
|
|
'error_description' => 'The refresh token is invalid',
|
|
|
|
|
], 400),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(TokenExpiredException::class, 'The refresh token is invalid');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
test('linkedin page publisher handles empty content', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update(['content' => '']);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
|
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:1234567890');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
return $request['commentary'] === '';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-11 15:36:14 +00:00
|
|
|
test('linkedin page publisher builds feed url from the post id regardless of username', function (?string $username) {
|
|
|
|
|
$this->socialAccount->update(['username' => $username]);
|
|
|
|
|
|
2026-01-18 18:56:51 +00:00
|
|
|
Http::fake([
|
2026-06-25 00:05:09 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
2026-01-18 18:56:51 +00:00
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
2026-08-11 15:36:14 +00:00
|
|
|
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:1234567890');
|
|
|
|
|
})->with([
|
|
|
|
|
'username present' => ['testcompany'],
|
|
|
|
|
'username missing' => [null],
|
|
|
|
|
]);
|
2026-01-18 18:56:51 +00:00
|
|
|
|
2026-08-11 15:36:14 +00:00
|
|
|
test('linkedin page publisher returns a null url when the response has no post id', function () {
|
2026-01-18 18:56:51 +00:00
|
|
|
Http::fake([
|
2026-08-11 15:36:14 +00:00
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201),
|
2026-01-18 18:56:51 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
2026-08-11 15:36:14 +00:00
|
|
|
expect($result['url'])->toBeNull();
|
2026-01-18 18:56:51 +00:00
|
|
|
});
|
2026-04-01 13:51:53 +00:00
|
|
|
|
|
|
|
|
test('linkedin page publisher can publish post with image using organization urn', function () {
|
2026-04-15 23:11:36 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-media-id',
|
|
|
|
|
'path' => 'media/2026-01/test-image.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/test-image.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'test.jpg',
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-04-01 13:51:53 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/OrgFake';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'image' => 'urn:li:image:OrgFakeImageUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:9999999999']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Media download fallback
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:9999999999');
|
2026-08-11 15:36:14 +00:00
|
|
|
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:9999999999');
|
2026-04-01 13:51:53 +00:00
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/images'));
|
|
|
|
|
|
|
|
|
|
// Assert the post was created with organization URN as author
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')
|
|
|
|
|
&& ($request['author'] ?? '') === 'urn:li:organization:123456'
|
|
|
|
|
&& isset($request['content']['media']['id'])
|
|
|
|
|
);
|
|
|
|
|
});
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
|
refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 13:17:02 +00:00
|
|
|
test('linkedin page publisher publishes a multi-image carousel with image ids under the organization', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
['id' => 'm1', 'path' => 'media/c1.jpg', 'url' => 'https://example.com/c1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'c1.jpg'],
|
|
|
|
|
['id' => 'm2', 'path' => 'media/c2.jpg', 'url' => 'https://example.com/c2.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'c2.jpg'],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$imageUrns = ['urn:li:image:OrgCarousel1', 'urn:li:image:OrgCarousel2'];
|
|
|
|
|
$uploadUrls = ['https://www.linkedin.com/dms/upload/org/1', 'https://www.linkedin.com/dms/upload/org/2'];
|
|
|
|
|
$initCount = 0;
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($imageUrns, $uploadUrls, &$initCount) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/images')) {
|
|
|
|
|
$idx = $initCount % 2;
|
|
|
|
|
$initCount++;
|
|
|
|
|
|
|
|
|
|
return Http::response(['value' => ['uploadUrl' => $uploadUrls[$idx], 'image' => $imageUrns[$idx]]], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($uploadUrls as $uploadUrl) {
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:orgcarousel']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-image-content', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:orgcarousel');
|
2026-08-11 15:36:14 +00:00
|
|
|
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:orgcarousel');
|
refactor(linkedin): extract shared publisher base and fix multi-image carousel
The member and company-page publishers were ~95% identical (~1200 lines). Extract AbstractLinkedInPublisher holding the whole pipeline (media-inferred dispatch, post/carousel/document payloads, image/video/document upload, processing polling, error handling); each concrete publisher now only declares its platform, author URN, and (for pages) the post URL. ~640 duplicated lines removed, single source of truth.
Fix: multi-image posts sent each image URN under 'media', which LinkedIn's multiImage schema rejects with a 422 (unrecognized field 'media' / required 'id' missing). Send the URN under 'id'. Add assertions locking the 'id' field on both carousel tests, a company-page multi-image test that was missing, and a fail-fast assertion that a missing org id makes no HTTP call.
2026-06-25 13:17:02 +00:00
|
|
|
|
|
|
|
|
// Org carousel must author as the organization and send image URNs under `id` (not `media`).
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$data = $request->data();
|
|
|
|
|
$images = data_get($data, 'content.multiImage.images');
|
|
|
|
|
|
|
|
|
|
return data_get($data, 'author') === 'urn:li:organization:123456'
|
|
|
|
|
&& is_array($images)
|
|
|
|
|
&& count($images) === 2
|
|
|
|
|
&& data_get($images, '0.id') === 'urn:li:image:OrgCarousel1'
|
|
|
|
|
&& ! array_key_exists('media', $images[0]);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
test('linkedin page publisher can publish a document (pdf carousel) using organization urn', function () {
|
|
|
|
|
$this->postPlatform->update([
|
2026-06-25 00:05:09 +00:00
|
|
|
'content_type' => ContentType::LinkedInPagePost,
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
'meta' => ['document_title' => 'Company Deck'],
|
|
|
|
|
]);
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'doc-media-1',
|
|
|
|
|
'path' => 'media/2026-01/company-deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/company-deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf',
|
|
|
|
|
'original_filename' => 'company-deck.pdf',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/org/0';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response([
|
|
|
|
|
'value' => [
|
|
|
|
|
'uploadUrl' => $uploadUrl,
|
|
|
|
|
'document' => 'urn:li:document:OrgDocUrn',
|
|
|
|
|
],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'AVAILABLE'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/posts')) {
|
|
|
|
|
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:orgdoc999']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('urn:li:share:orgdoc999');
|
2026-08-11 15:36:14 +00:00
|
|
|
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:orgdoc999');
|
feat(linkedin): support PDF document (carousel) posts
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
2026-06-24 19:32:27 +00:00
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload'));
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/rest/posts')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ($request['author'] ?? '') === 'urn:li:organization:123456'
|
|
|
|
|
&& data_get($request->data(), 'content.media.id') === 'urn:li:document:OrgDocUrn'
|
|
|
|
|
&& data_get($request->data(), 'content.media.title') === 'Company Deck';
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-24 20:36:39 +00:00
|
|
|
|
|
|
|
|
test('linkedin page publisher throws and does not post when document processing fails', function () {
|
2026-06-25 00:05:09 +00:00
|
|
|
$this->postPlatform->update(['content_type' => ContentType::LinkedInPagePost]);
|
2026-06-24 20:36:39 +00:00
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'doc-media-1', 'path' => 'media/2026-01/company-deck.pdf',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/company-deck.pdf',
|
|
|
|
|
'mime_type' => 'application/pdf', 'original_filename' => 'company-deck.pdf',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$uploadUrl = 'https://www.linkedin.com/dms-uploads/document/org/fail';
|
|
|
|
|
|
|
|
|
|
Http::fake(function ($request) use ($uploadUrl) {
|
|
|
|
|
$url = $request->url();
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents') && str_contains($url, 'initializeUpload')) {
|
|
|
|
|
return Http::response(['value' => ['uploadUrl' => $uploadUrl, 'document' => 'urn:li:document:OrgFail']], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($url === $uploadUrl) {
|
|
|
|
|
return Http::response(null, 201);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (str_contains($url, '/rest/documents/')) {
|
|
|
|
|
return Http::response(['status' => 'PROCESSING_FAILED'], 200);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Http::response('fake-pdf-bytes', 200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
|
|
|
->toThrow(Exception::class, 'LinkedIn Page document processing failed');
|
|
|
|
|
|
|
|
|
|
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
|
|
|
|
|
});
|
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 18:31:05 +00:00
|
|
|
|
|
|
|
|
test('linkedin page publisher keeps links intact', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
|
|
|
|
|
$this->post->update(['content' => 'New post: https://acme.com/blog']);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
|
|
|
|
|
'x-restli-id' => 'urn:li:share:1234567890',
|
|
|
|
|
]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish($this->postPlatform);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')
|
|
|
|
|
&& $request['commentary'] === 'New post: https://acme.com/blog');
|
|
|
|
|
});
|