trypost/tests/Feature/Jobs/PublishToSocialPlatformTest.php
Paulo Castellano c48c774e23 feat: publishing engine improvements — rate limit retry, inline token refresh, per-platform queues, proactive refresh
- Add HasSocialHttpClient trait with 429 rate limit retry (3 attempts, 5s delay)
- Integrate trait into all 10 publishers (YouTube uses Google SDK)
- Add inline token refresh retry in PublishToSocialPlatform job
- Add per-platform Horizon queues via Platform::queue() and Platform::allQueues()
- Add RefreshExpiringTokens hourly command for proactive token refresh
- Fix token leaks: redact response bodies in all Log::error calls
- Fix token leaks: remove $response->body() from exception messages
- Fix ConnectionVerifier: redact all refresh error logs
- Fix null checks on API response IDs (Instagram, Threads, Pinterest, Facebook)
- Fix PublishPost::failed() to mark post as failed
- Fix StoreChunkedMediaRequest: validate max 1GB total size
- Fix scheduled_at validation: string → date
- Fix StoreMediaRequest: images max 10MB, videos max 1GB, only MP4 video
2026-04-01 10:51:53 -03:00

338 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
use App\Jobs\PublishToSocialPlatform;
use App\Jobs\SendNotification;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Mail::fake();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->linkedin()->create([
'workspace_id' => $this->workspace->id,
]);
$this->post = Post::factory()->scheduled()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->postPlatform = PostPlatform::factory()->linkedin()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'enabled' => true,
]);
});
test('publish to social platform marks platform as publishing', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
Event::assertDispatched(PostPlatformStatusUpdated::class);
});
test('publish to social platform marks platform as published on success', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
expect($this->postPlatform->platform_post_id)->toBe('post-123');
expect($this->postPlatform->platform_url)->toBe('https://linkedin.com/post/123');
});
test('publish to social platform marks platform as failed on error', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new Exception('API Error'));
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe('API Error');
});
test('publish to social platform disconnects account on token expired', function () {
Event::fake();
Mail::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '401'));
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->socialAccount->status)->toBe(AccountStatus::Disconnected);
});
test('publish to social platform updates post status when all platforms finished', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::Published);
});
test('publish to social platform marks post as partially published when some fail', function () {
Event::fake();
$socialAccount2 = SocialAccount::factory()->x()->create([
'workspace_id' => $this->workspace->id,
]);
$postPlatform2 = PostPlatform::factory()->x()->failed()->create([
'post_id' => $this->post->id,
'social_account_id' => $socialAccount2->id,
'enabled' => true,
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::PartiallyPublished);
});
test('publish to social platform marks post as failed when all platforms fail', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new Exception('API Error'));
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::Failed);
});
test('publish to social platform skips publishing when account is disconnected', function () {
Event::fake();
$this->socialAccount->update([
'status' => AccountStatus::Disconnected,
'disconnected_at' => now(),
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});
test('publish to social platform skips publishing when account is inactive', function () {
Event::fake();
$this->socialAccount->update(['is_active' => false]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_inactive'));
});
test('publish to social platform skips publishing when account token is expired', function () {
Event::fake();
$this->socialAccount->update([
'status' => AccountStatus::TokenExpired,
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});
test('publish to social platform dispatches success notification when all platforms published', function () {
Event::fake();
Queue::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andReturn([
'id' => 'post-123',
'url' => 'https://linkedin.com/post/123',
]);
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::Published);
Queue::assertPushed(SendNotification::class);
});
test('publish to social platform dispatches failure notification when platform fails', function () {
Event::fake();
Queue::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new Exception('API error'));
$this->app->instance(LinkedInPublisher::class, $publisher);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Owner->value]);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->post->refresh();
expect($this->post->status)->toBe(PostStatus::Failed);
Queue::assertPushed(SendNotification::class);
});
test('it retries with token refresh when token expires during publish', function () {
Event::fake();
$callCount = 0;
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')
->twice()
->andReturnUsing(function () use (&$callCount) {
$callCount++;
if ($callCount === 1) {
throw new TokenExpiredException('Token expired', '401');
}
return ['id' => 'post-123', 'url' => 'https://linkedin.com/post/123'];
});
$this->app->instance(LinkedInPublisher::class, $publisher);
$verifier = Mockery::mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
$this->app->instance(ConnectionVerifier::class, $verifier);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
expect($this->socialAccount->status)->not->toBe(AccountStatus::Disconnected);
});
test('it disconnects account when token refresh fails during publish retry', function () {
Event::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')
->once()
->andThrow(new TokenExpiredException('Token expired', '401'));
$this->app->instance(LinkedInPublisher::class, $publisher);
$verifier = Mockery::mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andThrow(new Exception('Refresh failed'));
$this->app->instance(ConnectionVerifier::class, $verifier);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->socialAccount->status)->toBe(AccountStatus::Disconnected);
});
test('publish to social platform skips if already published (idempotency)', function () {
Event::fake();
// Mark as already published
$this->postPlatform->update([
'status' => PlatformStatus::Published,
'platform_post_id' => 'existing-123',
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
// Should not have called publish
$this->postPlatform->refresh();
expect($this->postPlatform->platform_post_id)->toBe('existing-123');
});