fix: LinkedIn company page post URL missing the post ID (#272)

* fix: LinkedIn company page post URL missing the post ID

LinkedInPagePublisher::postUrl() built a company/{username}/posts/
URL but never interpolated $postId into it, so the "view on LinkedIn"
link for company page posts always ended at the trailing slash.

Per LinkedIn's Posts API docs, the correct public URL for any
published post (member or organization) is always
feed/update/{postId} — so the override is removed and the class now
inherits the correct base implementation.

Closes #271

* address code review: backfill historical URLs, fix stale docblock, close test gaps

- Add social:backfill-linkedin-page-post-urls to recompute platform_url
  for LinkedIn Page posts published before the fix, whose broken
  company/{username}/posts/ URL was already persisted and never
  recomputed. Supports --dry-run.
- Fix AbstractLinkedInPublisher::postUrl() docblock, which still
  claimed company pages override it after the override was removed.
- LinkedInPagePublisherTest: assert result['url'] in the image and
  carousel publish tests (previously only asserted result['id']);
  add a null-postId case; collapse the two username-branch tests
  (now dead code) into one dataset-driven test plus a dedicated
  null-postId test.
This commit is contained in:
Paulo Castellano 2026-08-11 12:36:14 -03:00 committed by GitHub
parent eb52b6b699
commit 29e90c52af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 176 additions and 30 deletions

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use Illuminate\Console\Command;
/**
* One-time repair for LinkedIn Page posts published before the fix for
* https://github.com/trypostit/trypost/issues/271 LinkedInPagePublisher::postUrl()
* built "company/{username}/posts/" without appending the post ID, so those rows
* had that broken, unusable URL persisted to platform_url.
*/
class BackfillLinkedInPagePostUrls extends Command
{
protected $signature = 'social:backfill-linkedin-page-post-urls {--dry-run : Preview affected rows without writing changes}';
protected $description = 'Recompute platform_url for LinkedIn Page posts published with the pre-fix company/{username}/posts/ URL (issue #271)';
public function handle(): void
{
$dryRun = (bool) $this->option('dry-run');
$count = 0;
PostPlatform::query()
->where('platform', Platform::LinkedInPage)
->where('status', Status::Published)
->whereNotNull('platform_post_id')
->where('platform_url', 'like', 'https://www.linkedin.com/company/%/posts/')
->each(function (PostPlatform $postPlatform) use ($dryRun, &$count) {
$correctedUrl = "https://www.linkedin.com/feed/update/{$postPlatform->platform_post_id}";
$this->line("{$postPlatform->id}: {$postPlatform->platform_url} -> {$correctedUrl}");
if (! $dryRun) {
$postPlatform->update(['platform_url' => $correctedUrl]);
}
$count++;
});
$verb = $dryRun ? 'Would fix' : 'Fixed';
$this->info("{$verb} {$count} LinkedIn Page post URL(s).");
}
}

View file

@ -47,8 +47,10 @@ abstract protected function platform(): Platform;
abstract protected function authorUrn(): string;
/**
* Public URL of the created post. Defaults to the member feed update;
* company pages override it.
* Public URL of the created post. Per LinkedIn's Posts API docs, the
* feed/update permalink applies to any published post regardless of
* whether the author is a member or an organization, so subclasses
* (member vs. company page) share this implementation.
*/
protected function postUrl(?string $postId): ?string
{

View file

@ -31,15 +31,4 @@ protected function authorUrn(): string
return "urn:li:organization:{$organizationId}";
}
protected function postUrl(?string $postId): ?string
{
if (! $postId) {
return null;
}
return $this->account->username
? "https://www.linkedin.com/company/{$this->account->username}/posts/"
: "https://www.linkedin.com/feed/update/{$postId}";
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
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,
'username' => 'testcompany',
]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
});
test('it corrects broken linkedin page post urls', function () {
$broken = PostPlatform::factory()->published()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedInPage,
'platform_post_id' => 'urn:li:share:1234567890',
'platform_url' => 'https://www.linkedin.com/company/testcompany/posts/',
]);
$this->artisan('social:backfill-linkedin-page-post-urls')->assertSuccessful();
$broken->refresh();
expect($broken->platform_url)->toBe('https://www.linkedin.com/feed/update/urn:li:share:1234567890');
});
test('it leaves already-correct linkedin page urls untouched', function () {
$correct = PostPlatform::factory()->published()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedInPage,
'platform_post_id' => 'urn:li:share:1234567890',
'platform_url' => 'https://www.linkedin.com/feed/update/urn:li:share:1234567890',
]);
$this->artisan('social:backfill-linkedin-page-post-urls')->assertSuccessful();
$correct->refresh();
expect($correct->platform_url)->toBe('https://www.linkedin.com/feed/update/urn:li:share:1234567890');
});
test('it does not touch other platforms with a similarly shaped url', function () {
$other = PostPlatform::factory()->published()->create([
'post_id' => $this->post->id,
'social_account_id' => SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]),
'platform' => Platform::LinkedIn,
'platform_post_id' => 'urn:li:share:9999999999',
'platform_url' => 'https://www.linkedin.com/company/testcompany/posts/',
]);
$this->artisan('social:backfill-linkedin-page-post-urls')->assertSuccessful();
$other->refresh();
expect($other->platform_url)->toBe('https://www.linkedin.com/company/testcompany/posts/');
});
test('dry run previews without writing changes', function () {
$broken = PostPlatform::factory()->published()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedInPage,
'platform_post_id' => 'urn:li:share:1234567890',
'platform_url' => 'https://www.linkedin.com/company/testcompany/posts/',
]);
$this->artisan('social:backfill-linkedin-page-post-urls', ['--dry-run' => true])->assertSuccessful();
$broken->refresh();
expect($broken->platform_url)->toBe('https://www.linkedin.com/company/testcompany/posts/');
});
test('it ignores linkedin page rows that are not published', function () {
$failed = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::LinkedInPage,
'status' => Status::Failed,
'platform_post_id' => 'urn:li:share:1234567890',
'platform_url' => 'https://www.linkedin.com/company/testcompany/posts/',
]);
$this->artisan('social:backfill-linkedin-page-post-urls')->assertSuccessful();
$failed->refresh();
expect($failed->platform_url)->toBe('https://www.linkedin.com/company/testcompany/posts/');
});

View file

@ -57,7 +57,7 @@
expect($result)->toHaveKey('id');
expect($result)->toHaveKey('url');
expect($result['id'])->toBe('urn:li:share:1234567890');
expect($result['url'])->toContain('linkedin.com/company/testcompany/posts/');
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:1234567890');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/rest/posts')
@ -203,20 +203,8 @@
});
});
test('linkedin page publisher builds correct company url when username present', function () {
Http::fake([
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
'x-restli-id' => 'urn:li:share:1234567890',
]),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['url'])->toContain('linkedin.com/company/testcompany/posts/');
});
test('linkedin page publisher builds feed url when username missing', function () {
$this->socialAccount->update(['username' => null]);
test('linkedin page publisher builds feed url from the post id regardless of username', function (?string $username) {
$this->socialAccount->update(['username' => $username]);
Http::fake([
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201, [
@ -226,7 +214,20 @@
$result = $this->publisher->publish($this->postPlatform);
expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:1234567890');
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:1234567890');
})->with([
'username present' => ['testcompany'],
'username missing' => [null],
]);
test('linkedin page publisher returns a null url when the response has no post id', function () {
Http::fake([
config('trypost.platforms.linkedin-page.api').'/rest/posts' => Http::response(null, 201),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['url'])->toBeNull();
});
test('linkedin page publisher can publish post with image using organization urn', function () {
@ -271,6 +272,7 @@
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:9999999999');
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:9999999999');
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/images'));
@ -319,6 +321,7 @@
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:orgcarousel');
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:orgcarousel');
// Org carousel must author as the organization and send image URNs under `id` (not `media`).
Http::assertSent(function ($request) {
@ -385,7 +388,7 @@
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:orgdoc999');
expect($result['url'])->toContain('linkedin.com/company/testcompany/posts/');
expect($result['url'])->toBe('https://www.linkedin.com/feed/update/urn:li:share:orgdoc999');
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/documents') && str_contains($request->url(), 'initializeUpload'));
Http::assertSent(function ($request) {