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 marks account as token expired on auth failure', 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::TokenExpired); }); test('publish reschedules platform unavailable retry via Bus dispatch (not marked Failed, not expired)', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) ); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); $this->socialAccount->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying); expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(1); expect($this->socialAccount->status)->toBe(AccountStatus::Connected); Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) { return $job->postPlatform->id === $this->postPlatform->id; }); }); test('publish reschedules retry when retry-refresh path hits platform unavailable', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); // Publisher first throws TokenExpired (401-style), the retry-refresh // path goes through ConnectionVerifier::verify which can in turn raise // PlatformUnavailable if the platform is down. $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '401')); $verifier = Mockery::mock(ConnectionVerifier::class); $verifier->shouldReceive('verify')->andThrow( new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) ); $this->app->instance(LinkedInPublisher::class, $publisher); $this->app->instance(ConnectionVerifier::class, $verifier); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); $this->socialAccount->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying); expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); expect($this->socialAccount->status)->toBe(AccountStatus::Connected); Bus::assertDispatched(PublishToSocialPlatform::class); }); test('publish reschedules retry exactly 10 minutes into the future', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); $now = now()->startOfMinute(); Carbon::setTestNow($now); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( new PlatformUnavailableException('LinkedIn 503', 503) ); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); // error_context tracks the next attempt — must be exactly +10 min expect($this->postPlatform->error_context['next_attempt_at'] ?? null) ->toBe($now->copy()->addMinutes(10)->toIso8601String()); // The actual dispatched job carries the same delay Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) use ($now) { // $job->delay is a Carbon|DateInterval|int set by ->delay(...) $delayAt = $job->delay instanceof DateTimeInterface ? Carbon::instance($job->delay) : null; return $delayAt !== null && $delayAt->equalTo($now->copy()->addMinutes(10)); }); Carbon::setTestNow(); }); test('publish records last_attempt_at when rescheduling for retry', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); $now = now()->startOfMinute(); Carbon::setTestNow($now); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( new PlatformUnavailableException('LinkedIn 503', 503) ); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); expect($this->postPlatform->error_context['last_attempt_at'] ?? null) ->toBe($now->toIso8601String()); Carbon::setTestNow(); }); test('post stays in Publishing while one platform is still Retrying', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); // Second LinkedIn account on the same post — first one will publish OK, // second one will hit PlatformUnavailable and reschedule. $secondAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); $secondPlatform = PostPlatform::factory()->linkedin()->create([ 'post_id' => $this->post->id, 'social_account_id' => $secondAccount->id, 'enabled' => true, 'status' => PlatformStatus::Published, // simulate already published 'platform_post_id' => 'sibling-123', ]); // Start the post as Publishing so updatePostStatus sees the in-flight context $this->post->update(['status' => PostStatus::Publishing]); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( new PlatformUnavailableException('LinkedIn 503', 503) ); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); $this->post->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying); // Post is NOT finalized because one of its platforms is still pending retry. expect($this->post->status)->toBe(PostStatus::Publishing); }); test('successful publish after a retry transitions the platform to Published', function () { // Pre-condition: this platform already failed once and is currently Retrying. $this->postPlatform->update([ 'status' => PlatformStatus::Retrying, 'error_context' => ['retry_count' => 3, 'category' => 'platform_unavailable'], ]); Event::fake(); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andReturn([ 'id' => 'post-after-retry', 'url' => 'https://linkedin.com/post/after-retry', ]); $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-after-retry'); }); test('publish retry count increments across successive platform_unavailable attempts', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( new PlatformUnavailableException('LinkedIn 503', 503) ); $this->app->instance(LinkedInPublisher::class, $publisher); // Simulate prior attempts $this->postPlatform->update([ 'error_context' => ['retry_count' => 5], ]); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(6); }); 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 token is expired', function () { Event::fake(); $this->socialAccount->update([ 'status' => AccountStatus::TokenExpired, '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_token_expired')); expect($this->postPlatform->error_context['category'])->toBe('token_expired'); }); 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 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::Member->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::Member->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 marks account as token expired when 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::TokenExpired); }); 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'); }); test('publish to social platform saves error context on generic failure', function () { Event::fake(); $this->post->update(['content' => 'Test content here']); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow(new Exception('Something broke')); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); expect($this->postPlatform->error_context)->toBeArray(); expect($this->postPlatform->error_context['category'])->toBe('unknown'); expect($this->postPlatform->error_context['failed_at'])->toBeString(); expect($this->postPlatform->error_context['content_length'])->toBe(17); expect($this->postPlatform->error_context['media_count'])->toBe(0); }); test('publish to social platform saves error context on social publish exception', function () { Event::fake(); $this->post->update(['content' => 'Hello world']); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( new LinkedInPublishException( 'Not authorized to post', ErrorCategory::Permission, '403', '{"error": "forbidden"}', ) ); $this->app->instance(LinkedInPublisher::class, $publisher); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); expect($this->postPlatform->error_context)->toBeArray(); expect($this->postPlatform->error_context['category'])->toBe('permission'); expect($this->postPlatform->error_context['platform_error_code'])->toBe('403'); expect($this->postPlatform->error_context['content_length'])->toBe(11); }); test('publish to social platform fails when scopes are missing', function () { Event::fake(); $this->socialAccount->update(['scopes' => ['user.info.basic']]); // missing w_member_social $this->postPlatform->refresh(); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); expect($this->postPlatform->error_message)->toContain('Missing permissions'); expect($this->postPlatform->error_context['category'])->toBe('permission'); expect($this->postPlatform->error_context['missing_scopes'])->toContain('w_member_social'); }); test('publish to social platform saves error context on token expired', function () { Event::fake(); Mail::fake(); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '190')); $this->app->instance(LinkedInPublisher::class, $publisher); $verifier = Mockery::mock(ConnectionVerifier::class); $verifier->shouldReceive('verify')->andThrow(new TokenExpiredException('Refresh failed')); $this->app->instance(ConnectionVerifier::class, $verifier); (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); expect($this->postPlatform->error_context)->toBeArray(); expect($this->postPlatform->error_context['category'])->toBe('token_expired'); expect($this->postPlatform->error_context['platform_error_code'])->toBe('190'); });