diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 106a9f92..f281a90f 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -426,7 +426,7 @@ private function notify(Post $post, PostPlatformStatus $status): void ->enabled() ->where('status', $status) ->get() - ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') + ->map(fn ($pp) => $pp->notificationLabel()) ->implode(', '); SendNotification::dispatch( diff --git a/app/Mail/PostPublishFailed.php b/app/Mail/PostPublishFailed.php index 94415503..405d32ed 100644 --- a/app/Mail/PostPublishFailed.php +++ b/app/Mail/PostPublishFailed.php @@ -36,7 +36,7 @@ public function content(): Content ->get() ->filter(fn ($pp) => $pp->status === Status::Failed) ->map(fn ($pp) => [ - 'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', data_get($pp, 'socialAccount.display_name', '')).')', + 'name' => $pp->notificationLabel(), 'error' => $pp->error_message, ]) ->values() diff --git a/app/Mail/PostPublished.php b/app/Mail/PostPublished.php index ead767f8..8a51696e 100644 --- a/app/Mail/PostPublished.php +++ b/app/Mail/PostPublished.php @@ -36,7 +36,7 @@ public function content(): Content ->get() ->filter(fn ($pp) => $pp->status === Status::Published) ->map(fn ($pp) => [ - 'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')', + 'name' => $pp->notificationLabel(), 'url' => $pp->platform_url, ]) ->values() diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php index 35f0f0dd..a7316211 100644 --- a/app/Models/PostPlatform.php +++ b/app/Models/PostPlatform.php @@ -89,6 +89,25 @@ public function getDisplayUsernameAttribute(): ?string return $this->socialAccount?->username ?? $this->platform_username; } + /** + * "Facebook Page (@handle)" for emails and in-app notifications. + * Username first, then display name (live account or the snapshot + * kept on this row). When neither is set — or the account is gone + * and there is no snapshot — just the platform name, never "(@)". + */ + public function notificationLabel(): string + { + $identifier = $this->display_username + ?: $this->socialAccount?->display_name + ?: $this->platform_name; + + if (! filled($identifier) || $identifier === $this->platform->label()) { + return $this->platform->label(); + } + + return "{$this->platform->label()} (@{$identifier})"; + } + /** * Get avatar URL, falling back to snapshot if account was deleted. */ diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 16ef42bb..652a0c55 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -23,6 +23,7 @@ use App\Models\Workspace; use App\Services\Media\MediaOptimizer; use App\Services\Social\ConnectionVerifier; +use App\Services\Social\FacebookPublisher; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; use App\Services\Social\PinterestPublisher; @@ -1275,6 +1276,77 @@ Queue::assertPushed(SendNotification::class); }); +test('in-app published notification falls back to the facebook page display name', function () { + Event::fake(); + Queue::fake(); + + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => null, + 'display_name' => 'InboxPlacement.io', + ]); + $post = Post::factory()->scheduled()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $postPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + 'enabled' => true, + ]); + + $publisher = Mockery::mock(FacebookPublisher::class); + $publisher->shouldReceive('publish')->andReturn([ + 'id' => 'fb-123', + 'url' => 'https://www.facebook.com/permalink.php?story_fbid=pfbid0&id=61592851040951', + ]); + $this->app->instance(FacebookPublisher::class, $publisher); + + (new PublishToSocialPlatform($postPlatform))->handle(); + + Queue::assertPushed(SendNotification::class, function (SendNotification $job) use ($post) { + return $job->type === Type::PostPublished + && $job->title === 'Post published successfully' + && $job->body === 'Facebook Page (@InboxPlacement.io)' + && data_get($job->data, 'post_id') === $post->id; + }); +}); + +test('in-app failed notification falls back to the facebook page display name', function () { + Event::fake(); + Queue::fake(); + + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => null, + 'display_name' => 'InboxPlacement.io', + ]); + $post = Post::factory()->scheduled()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $postPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + 'enabled' => true, + ]); + + $publisher = Mockery::mock(FacebookPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new Exception('API error')); + $this->app->instance(FacebookPublisher::class, $publisher); + + (new PublishToSocialPlatform($postPlatform))->handle(); + + Queue::assertPushed(SendNotification::class, function (SendNotification $job) use ($post) { + return $job->type === Type::PostFailed + && $job->title === 'Post failed to publish' + && $job->body === 'Failed on: Facebook Page (@InboxPlacement.io)' + && data_get($job->data, 'post_id') === $post->id; + }); +}); + test('it retries with token refresh when token expires during publish', function () { Event::fake(); diff --git a/tests/Unit/Mail/PostPublishFailedTest.php b/tests/Unit/Mail/PostPublishFailedTest.php new file mode 100644 index 00000000..278c7ea8 --- /dev/null +++ b/tests/Unit/Mail/PostPublishFailedTest.php @@ -0,0 +1,29 @@ +create(['name' => 'InboxPlacement.io']); + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $workspace->id, + 'username' => null, + 'display_name' => 'InboxPlacement.io', + ]); + $post = Post::factory()->failed()->create(['workspace_id' => $workspace->id]); + PostPlatform::factory()->facebook()->failed()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + $mail = new PostPublishFailed($post); + + $mail->assertSeeInHtml('Facebook Page (@InboxPlacement.io)'); + $mail->assertDontSeeInHtml('Facebook Page (@)'); +}); diff --git a/tests/Unit/Mail/PostPublishedTest.php b/tests/Unit/Mail/PostPublishedTest.php new file mode 100644 index 00000000..d1f7b159 --- /dev/null +++ b/tests/Unit/Mail/PostPublishedTest.php @@ -0,0 +1,71 @@ +create(['name' => 'InboxPlacement.io']); + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $workspace->id, + 'username' => null, + 'display_name' => 'InboxPlacement.io', + ]); + $post = Post::factory()->published()->create(['workspace_id' => $workspace->id]); + PostPlatform::factory()->facebook()->published()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + 'platform_url' => 'https://www.facebook.com/permalink.php?story_fbid=pfbid0&id=61592851040951', + ]); + + $mail = new PostPublished($post); + + $mail->assertSeeInHtml('Facebook Page (@InboxPlacement.io)'); + $mail->assertDontSeeInHtml('Facebook Page (@)'); + $mail->assertSeeInHtml('https://www.facebook.com/permalink.php?story_fbid=pfbid0&id=61592851040951'); +}); + +test('published email uses the username when the display name is empty', function () { + $workspace = Workspace::factory()->create(['name' => 'InboxPlacement.io']); + $account = SocialAccount::factory()->bluesky()->create([ + 'workspace_id' => $workspace->id, + 'username' => 'inboxplacementio.bsky.social', + 'display_name' => '', + ]); + $post = Post::factory()->published()->create(['workspace_id' => $workspace->id]); + PostPlatform::factory()->bluesky()->published()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + $mail = new PostPublished($post); + + $mail->assertSeeInHtml('Bluesky (@inboxplacementio.bsky.social)'); + $mail->assertDontSeeInHtml('Bluesky (@)'); +}); + +test('published email omits empty parentheses when both identifiers are missing', function () { + $workspace = Workspace::factory()->create(['name' => 'InboxPlacement.io']); + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $workspace->id, + 'username' => null, + 'display_name' => '', + ]); + $post = Post::factory()->published()->create(['workspace_id' => $workspace->id]); + PostPlatform::factory()->facebook()->published()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + $mail = new PostPublished($post); + + $mail->assertSeeInHtml('Facebook Page'); + $mail->assertDontSeeInHtml('Facebook Page (@)'); +}); diff --git a/tests/Unit/PostPlatformTest.php b/tests/Unit/PostPlatformTest.php index 1daddc0a..5179fcbc 100644 --- a/tests/Unit/PostPlatformTest.php +++ b/tests/Unit/PostPlatformTest.php @@ -64,6 +64,84 @@ expect($this->postPlatform->fresh()->display_name)->toBe('Snapshot Name'); }); +test('notificationLabel prefers username over display name', function () { + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'myfbpage', + 'display_name' => 'InboxPlacement.io', + ]); + $postPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + expect($postPlatform->notificationLabel())->toBe('Facebook Page (@myfbpage)'); +}); + +test('notificationLabel falls back to display name when username is empty', function () { + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => null, + 'display_name' => 'InboxPlacement.io', + ]); + $postPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + expect($postPlatform->notificationLabel())->toBe('Facebook Page (@InboxPlacement.io)'); +}); + +test('notificationLabel uses username when display name is empty', function () { + $account = SocialAccount::factory()->bluesky()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'inboxplacementio.bsky.social', + 'display_name' => '', + ]); + $postPlatform = PostPlatform::factory()->bluesky()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + expect($postPlatform->notificationLabel())->toBe('Bluesky (@inboxplacementio.bsky.social)'); +}); + +test('notificationLabel omits empty parentheses when both identifiers are missing', function () { + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => null, + 'display_name' => '', + ]); + $postPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + ]); + + expect($postPlatform->notificationLabel())->toBe('Facebook Page'); +}); + +test('notificationLabel uses the snapshot when the account has been deleted', function () { + $account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => null, + 'display_name' => 'InboxPlacement.io', + ]); + $postPlatform = PostPlatform::factory()->facebook()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + 'platform_name' => 'InboxPlacement.io', + ]); + + $account->delete(); + + expect($postPlatform->fresh()->notificationLabel())->toBe('Facebook Page (@InboxPlacement.io)'); +}); + test('markAsFailed clears stale platform_post_id and platform_url', function () { $this->postPlatform->update([ 'status' => Status::Published,