From 4546425532db33896180e629d3dcff435722338c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 16 Aug 2026 15:34:53 -0300 Subject: [PATCH] Resume in-flight Instagram and TikTok publishes without duplicates (#281) * Improve asynchronous social publishing reliability * fix: resume asynchronous social publishes * fix: preserve publish checkpoints across retries * fix: harden resumable publish lifecycle * fix: clean retry resources on terminal failures * test: cover resumable social publishing edge cases * feat: add failed post retry command * chore: remove retry command ai rule * fix: require confirmation for post retries * chore: remove ai rules index * chore: remove ai social rule * refactor: clarify TikTok derivative path validation * refactor: simplify social publishing retries * refactor: further simplify social publishing retries * refactor: retry all failed post platforms * style: import throwable in social retries * refactor: decouple TikTok cleanup from image format * refactor: extract missing publish scopes * refactor: encapsulate missing scope failure * fix: resume failed publishes and treat Instagram rate limits as transient Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not start a duplicate remote post. Classify Meta BUC 400s on Instagram status polls as retryable via GraphError. Co-authored-by: Cursor * test: cover resume paths and transient Instagram rate limits Co-authored-by: Cursor * fix: resume posts:retry only for in-flight publish failures Co-authored-by: Cursor * fix: resume posts:retry via ErrorCategory instead of string lists Co-authored-by: Cursor * fix: handle Instagram PUBLISHED and EXPIRED container statuses Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish. Co-authored-by: Cursor * fix: recover published Instagram stories from /stories Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id. Co-authored-by: Cursor * test: cover Instagram EXPIRED retry and published recovery paths Co-authored-by: Cursor * fix: stop guessing Instagram media ids from recent /media Co-authored-by: Cursor * fix: checkpoint TikTok publish_id and keep in-flight photo derivatives Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure. Co-authored-by: Cursor * test: cover remaining TikTok in-flight derivative edge cases Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/. Co-authored-by: Cursor * refactor: map TikTok publish statuses with an official enum Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure. Co-authored-by: Cursor * refactor: share in-flight publish checkpoint keys Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys. Co-authored-by: Cursor * fix: retry Instagram media_publish after transient Graph failures A 500/code 2 after Meta already published left the job Failed as unknown. Treat that as still-processing so resume can confirm PUBLISHED instead of posting again. Co-authored-by: Cursor * fix: resume Instagram publish after dropped Graph connections A timeout or connection reset after Meta already published was marked unknown. Treat it as still-processing so resume can confirm PUBLISHED instead of posting again. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- app/Console/Commands/RecoverStuckPosts.php | 25 +- app/Console/Commands/RetryFailedPost.php | 202 +++++ app/Enums/Instagram/ContainerStatus.php | 19 + app/Enums/TikTok/PublishStatus.php | 19 + .../PlatformUnavailableException.php | 14 +- app/Exceptions/Social/ErrorCategory.php | 22 + app/Jobs/PublishToSocialPlatform.php | 201 +++-- app/Services/Social/BlueskyPublisher.php | 16 +- app/Services/Social/InstagramPublisher.php | 467 ++++++---- app/Services/Social/TikTokPublisher.php | 223 +++-- app/Support/Social/PublishCheckpoint.php | 51 ++ .../Social/TikTokPhotoDerivativeCleaner.php | 68 ++ config/trypost.php | 2 + .../Commands/RecoverStuckPostsTest.php | 102 +++ .../Feature/Commands/RetryFailedPostTest.php | 812 ++++++++++++++++++ .../Jobs/PublishToSocialPlatformTest.php | 446 +++++++++- .../Services/Social/BlueskyPublisherTest.php | 109 +++ .../Social/InstagramPublisherTest.php | 769 ++++++++++++++++- .../Services/Social/TikTokPublisherTest.php | 335 ++++++++ .../Social/SocialPublishExceptionTest.php | 32 +- .../Support/Social/PublishCheckpointTest.php | 31 + .../TikTokPhotoDerivativeCleanerTest.php | 85 ++ tests/Unit/TikTok/PublishStatusTest.php | 27 + 23 files changed, 3749 insertions(+), 328 deletions(-) create mode 100644 app/Console/Commands/RetryFailedPost.php create mode 100644 app/Enums/Instagram/ContainerStatus.php create mode 100644 app/Enums/TikTok/PublishStatus.php create mode 100644 app/Support/Social/PublishCheckpoint.php create mode 100644 app/Support/Social/TikTokPhotoDerivativeCleaner.php create mode 100644 tests/Feature/Commands/RetryFailedPostTest.php create mode 100644 tests/Unit/Support/Social/PublishCheckpointTest.php create mode 100644 tests/Unit/Support/Social/TikTokPhotoDerivativeCleanerTest.php create mode 100644 tests/Unit/TikTok/PublishStatusTest.php diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index 617a02ac..a8f74498 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -6,7 +6,10 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\Status as PlatformStatus; +use App\Exceptions\Social\ErrorCategory; use App\Models\Post; +use App\Models\PostPlatform; +use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Console\Command; class RecoverStuckPosts extends Command @@ -15,6 +18,12 @@ class RecoverStuckPosts extends Command protected $description = 'Recover posts stuck in publishing status for more than 1 hour'; + public function __construct( + private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner, + ) { + parent::__construct(); + } + public function handle(): void { $count = 0; @@ -23,18 +32,28 @@ public function handle(): void ->where('status', PostStatus::Publishing) ->where('updated_at', '<=', now()->subHour()) ->each(function (Post $post) use (&$count) { - $post->postPlatforms() + $stalePlatforms = $post->postPlatforms() ->enabled() ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) ->where('updated_at', '<=', now()->subHour()) - ->update([ + ->get(); + + $stalePlatforms->each(function (PostPlatform $postPlatform): void { + $this->tiktokPhotoDerivativeCleaner->cleanupUnlessPublishInFlight( + $postPlatform->error_context, + $postPlatform->id, + ); + + $postPlatform->update([ 'status' => PlatformStatus::Failed, 'error_message' => __('posts.errors.publishing_timed_out'), 'error_context' => [ - 'category' => 'timeout', + ...($postPlatform->error_context ?? []), + 'category' => ErrorCategory::Timeout->value, 'failed_at' => now()->toIso8601String(), ], ]); + }); // Delayed platform-unavailable retries keep the platform Retrying with a // fresh updated_at — do not finalize the post while that work is still live. diff --git a/app/Console/Commands/RetryFailedPost.php b/app/Console/Commands/RetryFailedPost.php new file mode 100644 index 00000000..6bc7e4ae --- /dev/null +++ b/app/Console/Commands/RetryFailedPost.php @@ -0,0 +1,202 @@ +find((string) $this->argument('post')); + + if (! $post) { + $this->error('Post not found.'); + + return self::FAILURE; + } + + if (! $this->isRetryable($post)) { + $this->error('Only failed or partially published posts can be retried.'); + + return self::FAILURE; + } + + $failedPlatforms = $this->failedPlatforms($post); + + if ($failedPlatforms->isEmpty()) { + $this->warn('No failed enabled platforms matched this post.'); + + return self::FAILURE; + } + + $this->table( + ['Post platform ID', 'Platform', 'Account', 'Last error', 'Mode'], + $failedPlatforms->map(fn (PostPlatform $postPlatform): array => [ + $postPlatform->id, + $postPlatform->platform->value, + $postPlatform->display_username ?? '—', + $postPlatform->error_message ?? '—', + $this->resumableContext($postPlatform->error_context) === null ? 'New' : 'Resume', + ])->all(), + ); + + if (! $this->confirm('Queue publish attempts for these failed platforms?')) { + $this->info('Retry cancelled.'); + + return self::SUCCESS; + } + + $retryEntries = $this->prepareRetryEntries($post); + + if ($retryEntries === []) { + $this->warn('The post changed while the command was running; nothing was retried.'); + + return self::FAILURE; + } + + foreach ($retryEntries as $entry) { + if ($entry['platform'] === SocialPlatform::TikTok && PublishCheckpoint::tiktokPublishId($entry['error_context']) === null) { + $this->tiktokPhotoDerivativeCleaner->cleanup($entry['original_error_context'], $entry['id']); + } + + $postPlatform = PostPlatform::query()->findOrFail($entry['id']); + PublishToSocialPlatform::dispatch($postPlatform); + } + + Log::info('Failed post platforms queued for manual retry', [ + 'post_id' => $post->id, + 'post_platform_ids' => array_column($retryEntries, 'id'), + ]); + + $this->info(count($retryEntries).' publish attempt(s) queued.'); + + return self::SUCCESS; + } + + private function isRetryable(Post $post): bool + { + return in_array($post->status, [PostStatus::Failed, PostStatus::PartiallyPublished], true); + } + + /** + * @return Collection + */ + private function failedPlatforms(Post $post, bool $lockForUpdate = false): Collection + { + return PostPlatform::query() + ->with('socialAccount') + ->where('post_id', $post->id) + ->enabled() + ->where('status', PlatformStatus::Failed) + ->when($lockForUpdate, fn (Builder $query) => $query->lockForUpdate()) + ->get(); + } + + /** + * @return list|null, + * original_error_context: array|null + * }> + */ + private function prepareRetryEntries(Post $post): array + { + return DB::transaction(function () use ($post): array { + $lockedPost = Post::query()->lockForUpdate()->find($post->id); + + if (! $lockedPost || ! $this->isRetryable($lockedPost)) { + return []; + } + + $platforms = $this->failedPlatforms($lockedPost, lockForUpdate: true); + + if ($platforms->isEmpty()) { + return []; + } + + $entries = []; + + foreach ($platforms as $postPlatform) { + $nextContext = $this->resumableContext($postPlatform->error_context); + $entries[] = [ + 'id' => $postPlatform->id, + 'platform' => $postPlatform->platform, + 'error_context' => $nextContext, + 'original_error_context' => $postPlatform->error_context, + ]; + + $postPlatform->update([ + 'status' => PlatformStatus::Pending, + 'platform_post_id' => null, + 'platform_url' => null, + 'error_message' => null, + 'error_context' => $nextContext, + 'published_at' => null, + ]); + } + $lockedPost->update(['status' => PostStatus::Publishing]); + + return $entries; + }); + } + + /** + * Keep in-flight checkpoints only. Confirmed remote failures must start over. + * + * @param array|null $context + * @return array|null + */ + private function resumableContext(?array $context): ?array + { + if (ErrorCategory::tryFromContext($context)?->isResumable() !== true) { + return null; + } + + $kept = []; + $publishId = PublishCheckpoint::tiktokPublishId($context); + $workflow = PublishCheckpoint::instagramWorkflow($context); + + if ($publishId !== null) { + $kept[PublishCheckpoint::TIKTOK_PUBLISH_ID] = $publishId; + $paths = PublishCheckpoint::tiktokDerivativePaths($context); + + if ($paths !== []) { + $kept[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $paths; + } + } + + if ($workflow !== null) { + $kept[PublishCheckpoint::INSTAGRAM_WORKFLOW] = $workflow; + } + + return $kept === [] ? null : $kept; + } +} diff --git a/app/Enums/Instagram/ContainerStatus.php b/app/Enums/Instagram/ContainerStatus.php new file mode 100644 index 00000000..9bf41fd2 --- /dev/null +++ b/app/Enums/Instagram/ContainerStatus.php @@ -0,0 +1,19 @@ + $context + */ public function __construct( string $message = 'Platform API is unavailable', public ?int $httpStatus = null, + public array $context = [], + public ?int $retryDelaySeconds = null, + public ?int $maxRetries = null, ) { parent::__construct($message); } diff --git a/app/Exceptions/Social/ErrorCategory.php b/app/Exceptions/Social/ErrorCategory.php index 4000c1a1..fa36c039 100644 --- a/app/Exceptions/Social/ErrorCategory.php +++ b/app/Exceptions/Social/ErrorCategory.php @@ -12,4 +12,26 @@ enum ErrorCategory: string case ContentPolicy = 'content_policy'; case ServerError = 'server_error'; case Unknown = 'unknown'; + case PlatformUnavailable = 'platform_unavailable'; + case Timeout = 'timeout'; + case TokenExpired = 'token_expired'; + case JobFailed = 'job_failed'; + + public function isResumable(): bool + { + return match ($this) { + self::PlatformUnavailable, self::Timeout, self::TokenExpired, self::JobFailed => true, + default => false, + }; + } + + /** + * @param array|null $context + */ + public static function tryFromContext(?array $context): ?self + { + $category = data_get($context, 'category'); + + return is_string($category) ? self::tryFrom($category) : null; + } } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 27a3a90e..106a9f92 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -11,6 +11,7 @@ use App\Enums\SocialAccount\Status; use App\Events\PostPlatformStatusUpdated; use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\SocialPublishException; use App\Exceptions\TokenExpiredException; use App\Mail\PostPublished; @@ -31,25 +32,32 @@ use App\Services\Social\TikTokPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; +use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Facades\Log; +use Throwable; class PublishToSocialPlatform implements ShouldBeUnique, ShouldQueue { use Queueable; - public int $tries = 1; + public int $tries = 20; + + public int $maxExceptions = 1; /** Download/upload + Pinterest poll headroom; keep Horizon/Redis timeouts above this. */ public int $timeout = 900; public int $uniqueFor = 960; - /** Max platform-unavailable reschedules (~1 hour at 10 min each). */ + /** Default platform-unavailable retry budget (~1 hour at 10 minutes each). */ public const MAX_PLATFORM_UNAVAILABLE_RETRIES = 6; + private const int DEFAULT_RETRY_DELAY_SECONDS = 600; + public function __construct( public PostPlatform $postPlatform, public int $uniqueAttempt = 0, @@ -62,6 +70,18 @@ public function uniqueId(): string return "{$this->postPlatform->id}:{$this->uniqueAttempt}"; } + /** + * @return array + */ + public function middleware(): array + { + return [ + (new WithoutOverlapping("social-publish:{$this->postPlatform->id}")) + ->releaseAfter(60) + ->expireAfter($this->timeout + 60), + ]; + } + public function handle(): void { $this->postPlatform->refresh(); @@ -71,48 +91,28 @@ public function handle(): void } if (! $this->postPlatform->socialAccount->is_active) { - $this->postPlatform->markAsFailed(__('posts.errors.account_inactive')); - $this->updatePostStatus(); - $this->broadcastStatus(); + $this->failAndFinalize(__('posts.errors.account_inactive')); return; } if ($this->postPlatform->socialAccount->status === Status::Disconnected) { - $this->postPlatform->markAsFailed(__('posts.errors.account_disconnected')); - $this->updatePostStatus(); - $this->broadcastStatus(); + $this->failAndFinalize(__('posts.errors.account_disconnected')); return; } if ($this->postPlatform->socialAccount->status === Status::TokenExpired) { - $this->postPlatform->markAsFailed(__('posts.errors.account_token_expired'), [ - 'category' => 'token_expired', + $this->failAndFinalize(__('posts.errors.account_token_expired'), [ + 'category' => ErrorCategory::TokenExpired->value, 'failed_at' => now()->toIso8601String(), ]); - $this->updatePostStatus(); - $this->broadcastStatus(); return; } - $requiredScopes = $this->postPlatform->platform->requiredPublishScopes(); - $accountScopes = $this->postPlatform->socialAccount->scopes ?? []; - - if (! empty($requiredScopes)) { - $missingScopes = array_diff($requiredScopes, $accountScopes); - - if (! empty($missingScopes)) { - $this->postPlatform->markAsFailed( - 'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.', - ['category' => 'permission', 'missing_scopes' => $missingScopes, 'failed_at' => now()->toIso8601String()] - ); - $this->updatePostStatus(); - $this->broadcastStatus(); - - return; - } + if ($this->failForMissingScopes()) { + return; } $this->postPlatform->markAsPublishing(); @@ -138,7 +138,7 @@ public function handle(): void } catch (PlatformUnavailableException $refreshError) { $this->rescheduleForRetry($refreshError); break; - } catch (\Throwable $refreshError) { + } catch (Throwable $refreshError) { Log::error('Token refresh failed during publish retry', [ 'post_platform_id' => $this->postPlatform->id, 'platform' => $this->postPlatform->platform->value, @@ -155,8 +155,8 @@ public function handle(): void 'platform_error_code' => $e->platformErrorCode, ]); - $this->postPlatform->markAsFailed($e->getMessage(), [ - 'category' => 'token_expired', + $this->markPlatformAsFailed($e->getMessage(), [ + 'category' => ErrorCategory::TokenExpired->value, 'platform_error_code' => $e->platformErrorCode, 'failed_at' => now()->toIso8601String(), ]); @@ -164,7 +164,7 @@ public function handle(): void break; } catch (SocialPublishException $e) { Log::error('Social publish failed: '.$e->userMessage); - $this->postPlatform->markAsFailed($e->userMessage, [ + $this->markPlatformAsFailed($e->userMessage, [ 'category' => $e->category->value, 'platform_error_code' => $e->platformErrorCode, 'failed_at' => now()->toIso8601String(), @@ -173,14 +173,14 @@ public function handle(): void 'raw_response' => $e->context()['raw_response'], ]); break; - } catch (\Throwable $e) { + } catch (Throwable $e) { Log::error('Failed to publish to social platform', [ 'post_platform_id' => $this->postPlatform->id, 'platform' => $this->postPlatform->platform->value, 'error' => $e->getMessage(), ]); - $this->postPlatform->markAsFailed($this->safeFailureMessage($e), [ - 'category' => 'unknown', + $this->markPlatformAsFailed($this->safeFailureMessage($e), [ + 'category' => ErrorCategory::Unknown->value, 'failed_at' => now()->toIso8601String(), 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), 'media_count' => count($this->postPlatform->post->media ?? []), @@ -204,24 +204,57 @@ private function refreshAccountToken(): void app(ConnectionVerifier::class)->verify($account); } + private function failForMissingScopes(): bool + { + $missingScopes = array_values(array_diff( + $this->postPlatform->platform->requiredPublishScopes(), + $this->postPlatform->socialAccount->scopes ?? [], + )); + + if ($missingScopes === []) { + return false; + } + + $this->failAndFinalize( + 'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.', + [ + 'category' => ErrorCategory::Permission->value, + 'missing_scopes' => $missingScopes, + 'failed_at' => now()->toIso8601String(), + ], + ); + + return true; + } + private function rescheduleForRetry(PlatformUnavailableException $e): void { $retryCount = (int) data_get($this->postPlatform->error_context, 'retry_count', 0) + 1; + $maxRetries = (int) ($e->maxRetries + ?? data_get($this->postPlatform->error_context, 'max_retries') + ?? self::MAX_PLATFORM_UNAVAILABLE_RETRIES); + $retryDelaySeconds = (int) ($e->retryDelaySeconds + ?? data_get($this->postPlatform->error_context, 'retry_delay_seconds') + ?? self::DEFAULT_RETRY_DELAY_SECONDS); $context = [ - 'category' => 'platform_unavailable', + ...($this->postPlatform->error_context ?? []), + ...$e->context, + 'category' => ErrorCategory::PlatformUnavailable->value, 'http_status' => $e->httpStatus, 'retry_count' => $retryCount, + 'max_retries' => $maxRetries, + 'retry_delay_seconds' => $retryDelaySeconds, 'detail' => $e->getMessage(), ]; - if ($retryCount > self::MAX_PLATFORM_UNAVAILABLE_RETRIES) { + if ($retryCount > $maxRetries) { Log::warning('Publish retries exhausted: platform unavailable', [ 'post_platform_id' => $this->postPlatform->id, 'platform' => $this->postPlatform->platform->value, ...$context, ]); - $this->postPlatform->markAsFailed( + $this->markPlatformAsFailed( __('posts.errors.platform_unavailable_exhausted'), [...$context, 'failed_at' => now()->toIso8601String()], ); @@ -229,7 +262,7 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void return; } - $nextAttemptAt = now()->addMinutes(10); + $nextAttemptAt = now()->addSeconds($retryDelaySeconds); Log::warning('Publish rescheduled: platform unavailable', [ 'post_platform_id' => $this->postPlatform->id, @@ -251,6 +284,35 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void self::dispatch($this->postPlatform, $retryCount)->delay($nextAttemptAt); } + /** + * @param array|null $context + */ + private function markPlatformAsFailed(string $message, ?array $context = null): void + { + $previousContext = $this->postPlatform->error_context ?? []; + + if ($this->postPlatform->platform === SocialPlatform::TikTok) { + app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight( + $previousContext, + $this->postPlatform->id, + ); + } + + $failureContext = [...$previousContext, ...($context ?? [])]; + + $this->postPlatform->markAsFailed($message, $failureContext === [] ? null : $failureContext); + } + + /** + * @param array|null $context + */ + private function failAndFinalize(string $message, ?array $context = null): void + { + $this->markPlatformAsFailed($message, $context); + $this->updatePostStatus(); + $this->broadcastStatus(); + } + private function isTerminal(): bool { return in_array($this->postPlatform->status, [ @@ -268,7 +330,7 @@ private function broadcastStatus(): void * A user-safe failure message: only our own publish exceptions are shown * verbatim; anything else is genericized so internals never reach the email. */ - private function safeFailureMessage(\Throwable $e): string + private function safeFailureMessage(Throwable $e): string { return $e instanceof SocialPublishException ? $e->userMessage @@ -311,45 +373,21 @@ private function updatePostStatus(): void if ($publishedCount === $total) { $post->markAsPublished(); - $this->notifySuccess($post); - } elseif ($publishedCount > 0) { - $post->markAsPartiallyPublished(); - $this->notifyFailure($post); - } else { - $post->markAsFailed(); - $this->notifyFailure($post); - } - } + $this->notify($post, PostPlatformStatus::Published); - private function notifySuccess(Post $post): void - { - $owner = $post->workspace->owner; - - if (! $owner) { return; } - $publishedPlatforms = $post->postPlatforms() - ->with('socialAccount') - ->enabled() - ->get() - ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published) - ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') - ->implode(', '); + if ($publishedCount > 0) { + $post->markAsPartiallyPublished(); + } else { + $post->markAsFailed(); + } - SendNotification::dispatch( - user: $owner, - workspaceId: $post->workspace_id, - type: Type::PostPublished, - channel: Channel::Both, - title: 'Post published successfully', - body: $publishedPlatforms, - data: ['post_id' => $post->id], - mailable: new PostPublished($post), - ); + $this->notify($post, PostPlatformStatus::Failed); } - public function failed(?\Throwable $exception): void + public function failed(?Throwable $exception): void { Log::error('PublishToSocialPlatform job failed permanently', [ 'post_platform_id' => $this->postPlatform->id, @@ -363,10 +401,10 @@ public function failed(?\Throwable $exception): void return; } - $this->postPlatform->markAsFailed( + $this->markPlatformAsFailed( $exception ? $this->safeFailureMessage($exception) : 'Unknown error', [ - 'category' => 'job_failed', + 'category' => ErrorCategory::JobFailed->value, 'failed_at' => now()->toIso8601String(), ] ); @@ -374,7 +412,7 @@ public function failed(?\Throwable $exception): void $this->broadcastStatus(); } - private function notifyFailure(Post $post): void + private function notify(Post $post, PostPlatformStatus $status): void { $owner = $post->workspace->owner; @@ -382,23 +420,24 @@ private function notifyFailure(Post $post): void return; } - $failedPlatforms = $post->postPlatforms() + $successful = $status === PostPlatformStatus::Published; + $platforms = $post->postPlatforms() ->with('socialAccount') ->enabled() + ->where('status', $status) ->get() - ->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed) ->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')') ->implode(', '); SendNotification::dispatch( user: $owner, workspaceId: $post->workspace_id, - type: Type::PostFailed, + type: $successful ? Type::PostPublished : Type::PostFailed, channel: Channel::Both, - title: 'Post failed to publish', - body: "Failed on: {$failedPlatforms}", + title: $successful ? 'Post published successfully' : 'Post failed to publish', + body: $successful ? $platforms : "Failed on: {$platforms}", data: ['post_id' => $post->id], - mailable: new PostPublishFailed($post), + mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post), ); } } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 7c6a128a..a881b5c7 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -20,6 +20,7 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Sleep; use RuntimeException; use Throwable; @@ -36,7 +37,7 @@ class BlueskyPublisher /** Re-upload a transiently-failing transcode this many times before giving up. */ private const VIDEO_UPLOAD_ATTEMPTS = 3; - /** Poll getJobStatus up to this many times (× the configured interval) before timing out. */ + /** Poll getJobStatus up to this many times before timing out. */ private const VIDEO_POLL_MAX_ATTEMPTS = 150; /** Wall-clock budget (seconds) for the whole upload+poll+retry flow, kept under the 600s job timeout so a stuck transcode degrades to text instead of being killed mid-flight. */ @@ -477,8 +478,6 @@ private function attemptVideoUpload(SocialAccount $account, string $uploadToken, private function pollVideoJob(string $statusToken, string $jobId, CarbonInterface $deadline): ?array { $statusUrl = (string) config('trypost.platforms.bluesky.video_service').'/xrpc/'.BlueskyLexicon::VIDEO_GET_JOB_STATUS; - $intervalSeconds = (int) config('trypost.platforms.bluesky.video_poll_seconds'); - // Processing usually finishes within seconds. State is checked before // sleeping so an already-complete job returns at once. The attempt cap // and the wall-clock deadline both bound the loop. @@ -514,7 +513,7 @@ private function pollVideoJob(string $statusToken, string $jobId, CarbonInterfac return null; } - sleep($intervalSeconds); + Sleep::for($this->videoPollDelaySeconds($attempt))->seconds(); } Log::error('Bluesky video processing timed out', ['jobId' => $jobId]); @@ -522,6 +521,15 @@ private function pollVideoJob(string $statusToken, string $jobId, CarbonInterfac return null; } + private function videoPollDelaySeconds(int $attempt): int + { + $initialSeconds = max(0, (int) config('trypost.platforms.bluesky.video_poll_seconds')); + $maxSeconds = max($initialSeconds, (int) config('trypost.platforms.bluesky.video_poll_max_seconds')); + $multiplier = 2 ** intdiv($attempt, 3); + + return min($initialSeconds * $multiplier, $maxSeconds); + } + /** * uploadVideo returns the job status at the top level, while getJobStatus * wraps it under a `jobStatus` key. Fall back to the top level only when the diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 00a81dd2..21d97f21 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -4,14 +4,20 @@ namespace App\Services\Social; +use App\Enums\Instagram\ContainerStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\Social\SocialPublishException; use App\Models\PostPlatform; use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Services\Social\Meta\GraphError; +use App\Support\Social\PublishCheckpoint; +use Closure; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Log; @@ -22,8 +28,19 @@ class InstagramPublisher private string $baseUrl; + private PostPlatform $postPlatform; + + private const int STATUS_RETRY_DELAY_SECONDS = 10; + + private const int STATUS_MAX_RETRIES = 90; + + private const string WORKFLOW_CAROUSEL_CHILDREN = 'carousel_children'; + + private const string WORKFLOW_FINAL_CONTAINER = 'final_container'; + public function publish(PostPlatform $postPlatform): array { + $this->postPlatform = $postPlatform; $this->validateContentLength($postPlatform); $account = $postPlatform->socialAccount; @@ -38,6 +55,12 @@ public function publish(PostPlatform $postPlatform): array $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $pendingWorkflow = PublishCheckpoint::instagramWorkflow($postPlatform->error_context); + + if ($pendingWorkflow !== null) { + return $this->resumeWorkflow($instagramId, $accessToken, $content, $pendingWorkflow); + } + $media = $postPlatform->post->mediaItems; if ($media->isEmpty()) { @@ -94,65 +117,21 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s $params['alt_text'] = $alt; } - // Step 1: Create container - $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); + $containerId = $this->createContainer($instagramId, $params, 'container'); - if ($containerResponse->failed()) { - Log::error('Instagram container creation failed', [ - 'status' => $containerResponse->status(), - 'body' => $this->redactResponseBody($containerResponse->body()), - ]); - $this->handleApiError($containerResponse); - } - - $containerId = $containerResponse->json()['id'] ?? null; - - if (! $containerId) { - throw new InstagramPublishException( - userMessage: 'Instagram container creation failed: No container ID returned', - category: ErrorCategory::ServerError, - ); - } - - // Step 2: Wait for container to be ready - $this->waitForMediaProcessing($containerId, $accessToken); - - // Step 3: Publish container - return $this->publishContainer($instagramId, $accessToken, $containerId); + return $this->finishContainer($instagramId, $accessToken, $containerId); } private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array { - // Step 1: Create container for video/reel - $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ + $containerId = $this->createContainer($instagramId, [ 'video_url' => $media->url, 'caption' => $content, 'media_type' => 'REELS', 'access_token' => $accessToken, - ]); + ], 'reel container'); - if ($containerResponse->failed()) { - Log::error('Instagram reel container creation failed', [ - 'status' => $containerResponse->status(), - 'body' => $this->redactResponseBody($containerResponse->body()), - ]); - $this->handleApiError($containerResponse); - } - - $containerId = $containerResponse->json()['id'] ?? null; - - if (! $containerId) { - throw new InstagramPublishException( - userMessage: 'Instagram reel container creation failed: No container ID returned', - category: ErrorCategory::ServerError, - ); - } - - // Wait for video processing - $this->waitForMediaProcessing($containerId, $accessToken); - - // Step 2: Publish container - return $this->publishContainer($instagramId, $accessToken, $containerId); + return $this->finishContainer($instagramId, $accessToken, $containerId); } private function publishStory(string $instagramId, string $accessToken, $media): array @@ -171,37 +150,16 @@ private function publishStory(string $instagramId, string $accessToken, $media): $params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height')); } - // Step 1: Create story container - $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); + $containerId = $this->createContainer($instagramId, $params, 'story container'); - if ($containerResponse->failed()) { - Log::error('Instagram story container creation failed', [ - 'status' => $containerResponse->status(), - 'body' => $this->redactResponseBody($containerResponse->body()), - ]); - $this->handleApiError($containerResponse); - } - - $containerId = $containerResponse->json()['id'] ?? null; - - if (! $containerId) { - throw new InstagramPublishException( - userMessage: 'Instagram story container creation failed: No container ID returned', - category: ErrorCategory::ServerError, - ); - } - - // Step 2: Wait for media processing - $this->waitForMediaProcessing($containerId, $accessToken); - - // Step 3: Publish story container - return $this->publishContainer($instagramId, $accessToken, $containerId); + return $this->finishContainer($instagramId, $accessToken, $containerId); } private function publishCarousel(string $instagramId, string $accessToken, ?string $content, $mediaCollection, ?string $aspectRatio): array { // Step 1: Create containers for each media item $childContainers = []; + $processingChildContainers = []; foreach ($mediaCollection as $media) { $isVideo = $media->isVideo(); @@ -234,20 +192,19 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri continue; } - $childId = $containerResponse->json()['id'] ?? null; + $childId = $this->stringId(data_get($containerResponse->json(), 'id')); - if (! $childId) { + if ($childId === null) { Log::error('Instagram carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]); continue; } - // Wait for video processing if needed - if ($isVideo) { - $this->waitForMediaProcessing($childId, $accessToken); - } - $childContainers[] = $childId; + + if ($isVideo) { + $processingChildContainers[] = $childId; + } } if (empty($childContainers)) { @@ -257,73 +214,128 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri ); } - // Step 2: Create carousel container - $carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ + return $this->finishCarousel($instagramId, $accessToken, $content, $childContainers, $processingChildContainers); + } + + /** + * @param list $childContainers + * @param list $processingChildContainers + */ + private function finishCarousel(string $instagramId, string $accessToken, ?string $content, array $childContainers, array $processingChildContainers): array + { + $workflow = [ + 'stage' => self::WORKFLOW_CAROUSEL_CHILDREN, + 'child_container_ids' => $childContainers, + 'processing_child_container_ids' => $processingChildContainers, + ]; + + foreach ($processingChildContainers as $childId) { + $this->waitForMediaProcessing($childId, $accessToken, $workflow); + } + + $carouselId = $this->createContainer($instagramId, [ 'media_type' => 'CAROUSEL', 'caption' => $content, 'children' => implode(',', $childContainers), 'access_token' => $accessToken, + ], 'carousel container'); + + return $this->finishContainer($instagramId, $accessToken, $carouselId); + } + + /** + * @param array $workflow + */ + private function resumeWorkflow(string $instagramId, string $accessToken, ?string $content, array $workflow): array + { + $mediaId = $this->stringId(data_get($workflow, 'media_id')); + + if ($mediaId !== null) { + return $this->publishedMedia($mediaId, $accessToken); + } + + $stage = data_get($workflow, 'stage'); + + if ($stage === self::WORKFLOW_FINAL_CONTAINER) { + $containerId = $this->stringId(data_get($workflow, 'container_id')); + + if ($containerId !== null) { + return $this->finishContainer($instagramId, $accessToken, $containerId); + } + } + + if ($stage === self::WORKFLOW_CAROUSEL_CHILDREN) { + $children = $this->stringList(data_get($workflow, 'child_container_ids')); + $processingChildren = $this->stringList(data_get($workflow, 'processing_child_container_ids', [])); + + if ($children !== null && $children !== [] && $processingChildren !== null) { + return $this->finishCarousel( + $instagramId, + $accessToken, + $content, + $children, + $processingChildren, + ); + } + } + + throw new InstagramPublishException( + userMessage: 'Instagram publish state is invalid and cannot be resumed.', + category: ErrorCategory::ServerError, + ); + } + + private function finishContainer(string $instagramId, string $accessToken, string $containerId): array + { + $status = $this->waitForMediaProcessing($containerId, $accessToken, [ + 'stage' => self::WORKFLOW_FINAL_CONTAINER, + 'container_id' => $containerId, ]); - if ($carouselResponse->failed()) { - Log::error('Instagram carousel container creation failed', [ - 'body' => $this->redactResponseBody($carouselResponse->body()), - ]); - $this->handleApiError($carouselResponse); + if ($status === ContainerStatus::Published) { + return $this->alreadyPublishedContainer($containerId); } - $carouselId = $carouselResponse->json()['id'] ?? null; - - if (! $carouselId) { - throw new InstagramPublishException( - userMessage: 'Instagram carousel container creation failed: No container ID returned', - category: ErrorCategory::ServerError, - ); - } - - // Step 3: Wait for carousel to be ready - $this->waitForMediaProcessing($carouselId, $accessToken); - - // Step 4: Publish carousel - return $this->publishContainer($instagramId, $accessToken, $carouselId); + return $this->publishContainer($instagramId, $accessToken, $containerId); } private function publishContainer(string $instagramId, string $accessToken, string $containerId): array { - $publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [ - 'creation_id' => $containerId, - 'access_token' => $accessToken, - ]); + $workflow = [ + 'stage' => self::WORKFLOW_FINAL_CONTAINER, + 'container_id' => $containerId, + ]; + + $publishResponse = $this->sendGraphRequest( + fn (): Response => $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [ + 'creation_id' => $containerId, + 'access_token' => $accessToken, + ]), + $containerId, + $workflow, + ); if ($publishResponse->failed()) { Log::error('Instagram publish failed', [ 'status' => $publishResponse->status(), 'body' => $this->redactResponseBody($publishResponse->body()), ]); + + if (GraphError::isTransientFailure($publishResponse)) { + throw $this->pendingContainerException($containerId, $workflow, $publishResponse->status()); + } + $this->handleApiError($publishResponse); } - $mediaId = $publishResponse->json()['id'] ?? null; + $mediaId = $this->requireGraphId( + $publishResponse->json()['id'] ?? null, + 'Instagram publish failed: no media ID returned', + ); - if (! $mediaId) { - throw new InstagramPublishException( - userMessage: 'Instagram publish failed: no media ID returned', - category: ErrorCategory::ServerError, - ); - } + $this->rememberPublishedMedia($containerId, $mediaId); - // Get permalink - $permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [ - 'fields' => 'permalink', - 'access_token' => $accessToken, - ]); - - $permalink = $permalinkResponse->json()['permalink'] ?? null; - - return [ - 'id' => $mediaId, - 'url' => $permalink, - ]; + return $this->publishedMedia($mediaId, $accessToken); } protected function cropFailureException(string $message): SocialPublishException @@ -334,37 +346,202 @@ protected function cropFailureException(string $message): SocialPublishException ); } - private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void + /** + * @param array $workflow + */ + private function waitForMediaProcessing(string $containerId, string $accessToken, array $workflow): ContainerStatus { - for ($i = 0; $i < $maxAttempts; $i++) { - $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [ + $statusResponse = $this->sendGraphRequest( + fn (): Response => $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [ 'fields' => 'status_code', 'access_token' => $accessToken, + ]), + $containerId, + $workflow, + ); + + if ($statusResponse->failed()) { + if (! GraphError::isTransientFailure($statusResponse)) { + $this->handleApiError($statusResponse); + } + + throw $this->pendingContainerException($containerId, $workflow, $statusResponse->status()); + } + + $status = ContainerStatus::tryFrom((string) ($statusResponse->json()['status_code'] ?? '')); + + return match ($status) { + ContainerStatus::Finished, ContainerStatus::Published => $status, + ContainerStatus::Error => throw new InstagramPublishException( + userMessage: 'Instagram media processing failed', + category: ErrorCategory::ServerError, + ), + ContainerStatus::Expired => throw new InstagramPublishException( + userMessage: 'Media container expired. Please try again in a few minutes.', + category: ErrorCategory::ServerError, + ), + default => throw $this->pendingContainerException($containerId, $workflow), + }; + } + + /** + * Persist the media id before the permalink fetch so a crash after + * media_publish can resume with the real id instead of guessing from /media. + */ + private function rememberPublishedMedia(string $containerId, string $mediaId): void + { + $this->postPlatform->update([ + 'error_context' => [ + ...($this->postPlatform->error_context ?? []), + PublishCheckpoint::INSTAGRAM_WORKFLOW => [ + 'stage' => self::WORKFLOW_FINAL_CONTAINER, + 'container_id' => $containerId, + 'media_id' => $mediaId, + ], + ], + ]); + } + + /** + * @return array{id: string, url: string|null} + */ + private function publishedMedia(string $mediaId, string $accessToken): array + { + try { + $permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [ + 'fields' => 'permalink', + 'access_token' => $accessToken, + ]); + } catch (ConnectionException) { + return [ + 'id' => $mediaId, + 'url' => null, + ]; + } + + $permalink = data_get($permalinkResponse->json(), 'permalink'); + + return [ + 'id' => $mediaId, + 'url' => $this->stringId($permalink), + ]; + } + + /** + * The container node has no published media id. Listing /media or /stories + * and taking data.0 can bind a different post from the same account, so + * keep the container id until media_publish has checkpointed a media_id. + * + * @return array{id: string, url: string|null} + */ + private function alreadyPublishedContainer(string $containerId): array + { + return [ + 'id' => $containerId, + 'url' => null, + ]; + } + + /** + * @param Closure(): Response $request + * @param array|null $workflow + */ + private function sendGraphRequest(Closure $request, ?string $containerId = null, ?array $workflow = null): Response + { + try { + return $request(); + } catch (ConnectionException $e) { + if ($containerId !== null && $workflow !== null) { + throw $this->pendingContainerException($containerId, $workflow); + } + + throw new PlatformUnavailableException( + message: "Instagram API unreachable: {$e->getMessage()}", + retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS, + maxRetries: self::STATUS_MAX_RETRIES, + ); + } + } + + /** + * @param array $workflow + */ + private function pendingContainerException(string $containerId, array $workflow, ?int $httpStatus = null): PlatformUnavailableException + { + return new PlatformUnavailableException( + message: "Instagram is still processing container {$containerId}", + httpStatus: $httpStatus, + context: [PublishCheckpoint::INSTAGRAM_WORKFLOW => $workflow], + retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS, + maxRetries: self::STATUS_MAX_RETRIES, + ); + } + + /** + * @param array $parameters + */ + private function createContainer(string $instagramId, array $parameters, string $label): string + { + $response = $this->sendGraphRequest( + fn (): Response => $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $parameters), + ); + + if ($response->failed()) { + Log::error("Instagram {$label} creation failed", [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), ]); - if ($statusResponse->failed()) { - sleep(5); - - continue; - } - - $status = $statusResponse->json()['status_code'] ?? 'UNKNOWN'; - - if ($status === 'FINISHED') { - return; - } - - if ($status === 'ERROR') { - throw new InstagramPublishException( - userMessage: 'Instagram media processing failed', - category: ErrorCategory::ServerError, + if (GraphError::isTransientFailure($response)) { + throw new PlatformUnavailableException( + message: "Instagram {$label} creation failed transiently", + httpStatus: $response->status(), + retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS, + maxRetries: self::STATUS_MAX_RETRIES, ); } - sleep(5); + $this->handleApiError($response); } - Log::warning('Instagram media processing timeout, proceeding anyway'); + return $this->requireGraphId( + data_get($response->json(), 'id'), + "Instagram {$label} creation failed: No container ID returned", + ); + } + + private function requireGraphId(mixed $id, string $missingMessage): string + { + $resolved = $this->stringId($id); + + if ($resolved === null) { + throw new InstagramPublishException( + userMessage: $missingMessage, + category: ErrorCategory::ServerError, + ); + } + + return $resolved; + } + + private function stringId(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @return list|null + */ + private function stringList(mixed $values): ?array + { + if (! is_array($values)) { + return null; + } + + return array_values(array_filter( + $values, + fn (mixed $value): bool => $this->stringId($value) !== null, + )); } private function handleApiError(Response $response): never diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 0c50fa17..645ff67d 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -6,12 +6,16 @@ use App\DataTransferObjects\MediaItem; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PublishStatus; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Support\Social\PublishCheckpoint; +use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -24,7 +28,9 @@ class TikTokPublisher { use HasSocialHttpClient; - private const PHOTO_DERIVATIVE_DIRECTORY = 'social-tiktok-photos'; + private const int STATUS_RETRY_DELAY_SECONDS = 30; + + private const int STATUS_MAX_RETRIES = 120; private string $baseUrl; @@ -49,6 +55,16 @@ public function publish(PostPlatform $postPlatform): array $this->accessToken = $account->access_token; + $pendingPublishId = PublishCheckpoint::tiktokPublishId($postPlatform->error_context); + + if ($pendingPublishId !== null) { + return $this->completePublishWithCleanup( + $postPlatform, + $pendingPublishId, + PublishCheckpoint::tiktokDerivativePaths($postPlatform->error_context), + ); + } + $media = $postPlatform->post->mediaItems; if ($media->isEmpty()) { @@ -185,23 +201,11 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte $data = $response->json(); - $publishId = data_get($data, 'data.publish_id'); + $publishId = $this->requirePublishId(data_get($data, 'data.publish_id')); - if (! $publishId) { - throw new TikTokPublishException( - userMessage: 'TikTok did not return a publish_id', - category: ErrorCategory::ServerError, - ); - } + $this->rememberPublishId($postPlatform, $publishId); - // Wait for processing and get final status - $statusData = $this->waitForPublishStatus($publishId); - $postId = data_get($statusData, 'publicaly_available_post_id.0'); - - return [ - 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), - ]; + return $this->completePublish($postPlatform, $publishId); } private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array @@ -257,28 +261,16 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st $this->handleApiError($response); } - $data = $response->json(); + $publishId = $this->requirePublishId(data_get($response->json(), 'data.publish_id')); - $publishId = data_get($data, 'data.publish_id'); + $this->rememberPublishId($postPlatform, $publishId, $derivatives); + } catch (Throwable $e) { + app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives); - if (! $publishId) { - throw new TikTokPublishException( - userMessage: 'TikTok did not return a publish_id', - category: ErrorCategory::ServerError, - ); - } - - // Wait for processing and get final status - $statusData = $this->waitForPublishStatus($publishId); - $postId = data_get($statusData, 'publicaly_available_post_id.0'); - - return [ - 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), - ]; - } finally { - $this->pruneDerivatives($derivatives); + throw $e; } + + return $this->completePublishWithCleanup($postPlatform, $publishId, $derivatives); } /** @@ -345,7 +337,7 @@ private function hostResizedPhoto(string $tempInput): array $optimized = app(MediaOptimizer::class)->optimizeImage($tempInput, Platform::TikTok); try { - $path = self::PHOTO_DERIVATIVE_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + $path = TikTokPhotoDerivativeCleaner::DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; Storage::put($path, file_get_contents($optimized)); } finally { @unlink($optimized); @@ -364,65 +356,124 @@ private function hostResizedPhoto(string $tempInput): array } } - /** - * Remove hosted photo derivatives, swallowing storage errors so cleanup can - * never mask the publish result. - * - * @param list $paths - */ - private function pruneDerivatives(array $paths): void + private function waitForPublishStatus(string $publishId): array { - if ($paths === []) { - return; + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/status/fetch/", [ + 'publish_id' => $publishId, + ]); + + if ($response->failed()) { + if ($response->status() !== 429 && ! $response->serverError()) { + $this->handleApiError($response); + } + + throw $this->pendingPublishException($publishId, $response->status()); } + $data = $response->json(); + $status = PublishStatus::tryFrom((string) data_get($data, 'data.status', '')); + + return match ($status) { + PublishStatus::PublishComplete => data_get($data, 'data', []), + PublishStatus::Failed => throw TikTokPublishException::fromFailReason( + (string) data_get($data, 'data.fail_reason', 'Unknown error'), + json_encode($data), + ), + default => throw $this->pendingPublishException($publishId), + }; + } + + private function requirePublishId(mixed $publishId): string + { + $resolved = is_string($publishId) && $publishId !== '' ? $publishId : null; + + if ($resolved === null) { + throw new TikTokPublishException( + userMessage: 'TikTok did not return a publish_id', + category: ErrorCategory::ServerError, + ); + } + + return $resolved; + } + + /** + * Persist the publish_id before status polling so a crash after /init/ + * can resume without creating a second publish. + * + * @param list $derivatives + */ + private function rememberPublishId(PostPlatform $postPlatform, string $publishId, array $derivatives = []): void + { + $context = [ + ...($postPlatform->error_context ?? []), + PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId, + ]; + + if ($derivatives !== []) { + $context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives; + } + + $postPlatform->update([ + 'error_context' => $context, + ]); + } + + private function pendingPublishException(string $publishId, ?int $httpStatus = null): PlatformUnavailableException + { + return new PlatformUnavailableException( + message: "TikTok is still processing publish_id {$publishId}", + httpStatus: $httpStatus, + context: [PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId], + retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS, + maxRetries: self::STATUS_MAX_RETRIES, + ); + } + + /** + * Finish an in-flight publish and prune hosted photos only when TikTok + * confirmed the attempt is dead, or when it completed. Resumable + * interruptions (still processing, expired token, unexpected crash) + * must keep the files so a later status poll can still PULL_FROM_URL. + * + * @param array $derivatives + * @return array + */ + private function completePublishWithCleanup(PostPlatform $postPlatform, string $publishId, array $derivatives): array + { + $retainDerivatives = true; + try { - Storage::delete($paths); - } catch (Throwable $e) { - Log::warning('Failed to prune TikTok photo derivatives', [ - 'paths' => $paths, - 'exception' => $e->getMessage(), - ]); + $result = $this->completePublish($postPlatform, $publishId); + $retainDerivatives = false; + + return $result; + } catch (PlatformUnavailableException $e) { + $e->context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives; + + throw $e; + } catch (TikTokPublishException $e) { + $retainDerivatives = false; + + throw $e; + } finally { + if (! $retainDerivatives) { + app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives); + } } } - private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array + private function completePublish(PostPlatform $postPlatform, string $publishId): array { - for ($i = 0; $i < $maxAttempts; $i++) { - sleep(3); + $statusData = $this->waitForPublishStatus($publishId); + $postId = data_get($statusData, 'publicaly_available_post_id.0'); + $postId = is_string($postId) && $postId !== '' ? $postId : null; - $response = $this->getHttpClient() - ->post("{$this->baseUrl}/post/publish/status/fetch/", [ - 'publish_id' => $publishId, - ]); - - if ($response->failed()) { - Log::warning('TikTok status check failed', [ - 'attempt' => $i, - 'body' => $this->redactResponseBody($response->body()), - ]); - - continue; - } - - $data = $response->json(); - $status = data_get($data, 'data.status', 'UNKNOWN'); - - if ($status === 'PUBLISH_COMPLETE') { - return data_get($data, 'data', []); - } - - if (in_array($status, ['FAILED', 'PUBLISH_FAILED'])) { - $failReason = data_get($data, 'data.fail_reason', 'Unknown error'); - throw TikTokPublishException::fromFailReason($failReason, json_encode($data)); - } - - // PROCESSING_UPLOAD, PROCESSING_DOWNLOAD, SENDING_TO_USER_INBOX - continue waiting - } - - Log::warning('TikTok publish status timeout, returning publish_id anyway'); - - return ['publish_id' => $publishId]; + return [ + 'id' => $postId ?? $publishId, + 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), + ]; } private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): ?string diff --git a/app/Support/Social/PublishCheckpoint.php b/app/Support/Social/PublishCheckpoint.php new file mode 100644 index 00000000..6862d77e --- /dev/null +++ b/app/Support/Social/PublishCheckpoint.php @@ -0,0 +1,51 @@ +|null $context + */ + public static function tiktokPublishId(?array $context): ?string + { + $value = data_get($context, self::TIKTOK_PUBLISH_ID); + + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @param array|null $context + * @return array + */ + public static function tiktokDerivativePaths(?array $context): array + { + $paths = data_get($context, self::TIKTOK_DERIVATIVE_PATHS, []); + + return is_array($paths) ? $paths : []; + } + + /** + * @param array|null $context + * @return array|null + */ + public static function instagramWorkflow(?array $context): ?array + { + $workflow = data_get($context, self::INSTAGRAM_WORKFLOW); + + return is_array($workflow) && $workflow !== [] ? $workflow : null; + } +} diff --git a/app/Support/Social/TikTokPhotoDerivativeCleaner.php b/app/Support/Social/TikTokPhotoDerivativeCleaner.php new file mode 100644 index 00000000..1ff2e4af --- /dev/null +++ b/app/Support/Social/TikTokPhotoDerivativeCleaner.php @@ -0,0 +1,68 @@ +|null $context + */ + public function cleanup(?array $context, ?string $postPlatformId = null): void + { + $this->cleanupPaths(PublishCheckpoint::tiktokDerivativePaths($context), $postPlatformId); + } + + /** + * Keep hosted photos while a publish_id can still be resumed. + * + * @param array|null $context + */ + public function cleanupUnlessPublishInFlight(?array $context, ?string $postPlatformId = null): void + { + if (PublishCheckpoint::tiktokPublishId($context) !== null) { + return; + } + + $this->cleanup($context, $postPlatformId); + } + + /** + * @param array $paths + */ + public function cleanupPaths(array $paths, ?string $postPlatformId = null): void + { + $derivativePaths = array_values(array_filter( + $paths, + $this->isManagedDerivativePath(...), + )); + + if ($derivativePaths === []) { + return; + } + + try { + Storage::delete($derivativePaths); + } catch (Throwable $e) { + Log::warning('Failed to prune TikTok photo derivatives', [ + 'post_platform_id' => $postPlatformId, + 'error' => $e->getMessage(), + ]); + } + } + + private function isManagedDerivativePath(mixed $path): bool + { + return is_string($path) + && dirname($path) === self::DIRECTORY + && Str::isUuid(pathinfo($path, PATHINFO_FILENAME)); + } +} diff --git a/config/trypost.php b/config/trypost.php index acb68a9a..be8f0a55 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -185,6 +185,8 @@ 'video_service_did' => env('BLUESKY_VIDEO_SERVICE_DID', 'did:web:video.bsky.app'), // Seconds between transcode job-status polls. 'video_poll_seconds' => env('BLUESKY_VIDEO_POLL_SECONDS', 2), + // Gradually back off status checks to at most this interval. + 'video_poll_max_seconds' => env('BLUESKY_VIDEO_POLL_MAX_SECONDS', 30), // Bluesky rejects videos larger than 100 MB; skip oversized files early. 'video_max_bytes' => env('BLUESKY_VIDEO_MAX_BYTES', 100 * 1024 * 1024), // PLC directory, used to resolve an account's real PDS host from its DID. diff --git a/tests/Feature/Commands/RecoverStuckPostsTest.php b/tests/Feature/Commands/RecoverStuckPostsTest.php index a41fe08e..9c168bfb 100644 --- a/tests/Feature/Commands/RecoverStuckPostsTest.php +++ b/tests/Feature/Commands/RecoverStuckPostsTest.php @@ -14,6 +14,7 @@ use App\Services\Social\LinkedInPublisher; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; beforeEach(function () { $this->user = User::factory()->create(); @@ -139,6 +140,107 @@ ->and($post->status)->toBe(PostStatus::Failed); }); +test('it keeps TikTok photo derivatives when recovering a stuck in-flight publish', function () { + Storage::fake(); + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($path, 'image'); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Publishing, + 'updated_at' => now()->subHours(2), + ]); + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $platform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'status' => PlatformStatus::Retrying, + 'enabled' => true, + 'error_context' => [ + 'tiktok_publish_id' => 'publish-stuck', + 'tiktok_derivative_paths' => [$path], + ], + 'updated_at' => now()->subHours(2), + ]); + + $this->artisan('social:recover-stuck-posts')->assertSuccessful(); + + Storage::assertExists($path); + expect($platform->fresh()->error_context)->toMatchArray([ + 'tiktok_publish_id' => 'publish-stuck', + 'category' => 'timeout', + ]); +}); + +test('it prunes TikTok photo derivatives when recovering a stuck retry with no publish_id', function () { + Storage::fake(); + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($path, 'image'); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Publishing, + 'updated_at' => now()->subHours(2), + ]); + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $platform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'status' => PlatformStatus::Retrying, + 'enabled' => true, + 'error_context' => [ + 'tiktok_derivative_paths' => [$path], + ], + 'updated_at' => now()->subHours(2), + ]); + + $this->artisan('social:recover-stuck-posts')->assertSuccessful(); + + Storage::assertMissing($path); + expect($platform->fresh()->error_context['category'] ?? null)->toBe('timeout'); +}); + +test('it preserves an Instagram workflow when recovering a stuck retry', function () { + $workflow = [ + 'stage' => 'final_container', + 'container_id' => 'container-stuck', + ]; + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Publishing, + 'updated_at' => now()->subHours(2), + ]); + $account = SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + ]); + $platform = PostPlatform::factory()->instagram()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'status' => PlatformStatus::Retrying, + 'enabled' => true, + 'error_context' => [ + 'instagram_workflow' => $workflow, + 'retry_count' => 40, + ], + 'updated_at' => now()->subHours(2), + ]); + + $this->artisan('social:recover-stuck-posts')->assertSuccessful(); + + expect($platform->fresh()->status)->toBe(PlatformStatus::Failed) + ->and($platform->fresh()->error_context)->toMatchArray([ + 'instagram_workflow' => $workflow, + 'category' => 'timeout', + ]); +}); + test('it does not finalize a post while a platform is still actively retrying', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Commands/RetryFailedPostTest.php b/tests/Feature/Commands/RetryFailedPostTest.php new file mode 100644 index 00000000..97c6e8af --- /dev/null +++ b/tests/Feature/Commands/RetryFailedPostTest.php @@ -0,0 +1,812 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::PartiallyPublished, + 'published_at' => now()->subHour(), + ]); +}); + +test('it does not expose retry filters or confirmation bypasses', function () { + $command = Artisan::all()['posts:retry']; + + expect($command->getDefinition()->hasOption('force'))->toBeFalse() + ->and($command->getDefinition()->hasOption('platform'))->toBeFalse(); +}); + +test('it queues fresh attempts only for failed enabled platforms', function () { + Bus::fake([PublishToSocialPlatform::class]); + + $publishedPlatform = PostPlatform::factory()->published()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $this->workspace->id, + ]), + ]); + $failedThreads = PostPlatform::factory()->threads()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->threads()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'platform_post_id' => 'stale-post-id', + 'platform_url' => 'https://threads.net/stale', + 'published_at' => now()->subHour(), + 'error_context' => ['remote_operation_id' => 'stale-operation'], + ]); + $failedPinterest = PostPlatform::factory()->pinterest()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->pinterest()->create([ + 'workspace_id' => $this->workspace->id, + ]), + ]); + $disabledFailedPlatform = PostPlatform::factory()->tiktok()->failed()->disabled()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutput('2 publish attempt(s) queued.') + ->assertSuccessful(); + + expect($this->post->fresh()->status)->toBe(PostStatus::Publishing) + ->and($failedThreads->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedThreads->fresh()->platform_post_id)->toBeNull() + ->and($failedThreads->fresh()->platform_url)->toBeNull() + ->and($failedThreads->fresh()->published_at)->toBeNull() + ->and($failedThreads->fresh()->error_message)->toBeNull() + ->and($failedThreads->fresh()->error_context)->toBeNull() + ->and($failedPinterest->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($publishedPlatform->fresh()->status)->toBe(PlatformStatus::Published) + ->and($disabledFailedPlatform->fresh()->status)->toBe(PlatformStatus::Failed); + + Bus::assertDispatchedTimes(PublishToSocialPlatform::class, 2); + Bus::assertDispatched( + PublishToSocialPlatform::class, + fn (PublishToSocialPlatform $job): bool => $job->postPlatform->is($failedThreads) && $job->uniqueAttempt === 0, + ); +}); + +test('it resumes a TikTok publish_id instead of starting from scratch', function () { + Bus::fake([PublishToSocialPlatform::class]); + Storage::fake(); + + $derivativePath = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($derivativePath, 'temporary image'); + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'tiktok_publish_id' => 'stale-publish-id', + 'tiktok_derivative_paths' => [$derivativePath], + 'retry_count' => 120, + 'max_retries' => 120, + 'category' => 'platform_unavailable', + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + Storage::assertExists($derivativePath); + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedTikTok->fresh()->error_context)->toBe([ + 'tiktok_publish_id' => 'stale-publish-id', + 'tiktok_derivative_paths' => [$derivativePath], + ]); + + Bus::assertDispatched(PublishToSocialPlatform::class, fn (PublishToSocialPlatform $job): bool => $job->postPlatform->is($failedTikTok)); +}); + +test('it resumes a TikTok publish_id after an account or job interruption', function (ErrorCategory $category) { + Bus::fake([PublishToSocialPlatform::class]); + + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'tiktok_publish_id' => 'pub_in_flight', + 'category' => $category->value, + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedTikTok->fresh()->error_context)->toBe([ + 'tiktok_publish_id' => 'pub_in_flight', + ]); +})->with([ + 'token expired' => [ErrorCategory::TokenExpired], + 'job failed' => [ErrorCategory::JobFailed], +]); + +test('it keeps an Instagram workflow checkpoint on retry', function () { + Bus::fake([PublishToSocialPlatform::class]); + + $workflow = [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ]; + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'instagram_workflow' => $workflow, + 'retry_count' => 90, + 'max_retries' => 90, + 'category' => 'timeout', + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedInstagram->fresh()->error_context)->toBe([ + 'instagram_workflow' => $workflow, + ]); +}); + +test('it removes stale TikTok derivatives when there is no publish_id to resume', function () { + Bus::fake([PublishToSocialPlatform::class]); + Storage::fake(); + + $derivativePath = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($derivativePath, 'temporary image'); + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'tiktok_derivative_paths' => [$derivativePath], + 'category' => 'unknown', + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + Storage::assertMissing($derivativePath); + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedTikTok->fresh()->error_context)->toBeNull(); + + Bus::assertDispatched(PublishToSocialPlatform::class, fn (PublishToSocialPlatform $job): bool => $job->postPlatform->is($failedTikTok)); +}); + +test('it does not change the post when confirmation is declined', function () { + Bus::fake([PublishToSocialPlatform::class]); + + $failedPlatform = PostPlatform::factory()->threads()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->threads()->create([ + 'workspace_id' => $this->workspace->id, + ]), + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'no') + ->expectsOutput('Retry cancelled.') + ->assertSuccessful(); + + expect($this->post->fresh()->status)->toBe(PostStatus::PartiallyPublished) + ->and($failedPlatform->fresh()->status)->toBe(PlatformStatus::Failed); + + Bus::assertNotDispatched(PublishToSocialPlatform::class); +}); + +test('it rejects posts that are not in a terminal failure state', function () { + Bus::fake([PublishToSocialPlatform::class]); + $this->post->update(['status' => PostStatus::Publishing]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsOutput('Only failed or partially published posts can be retried.') + ->assertFailed(); + + Bus::assertNotDispatched(PublishToSocialPlatform::class); +}); + +test('it retries a completely failed post', function () { + Bus::fake([PublishToSocialPlatform::class]); + $this->post->update(['status' => PostStatus::Failed]); + $failedPlatform = PostPlatform::factory()->threads()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->threads()->create([ + 'workspace_id' => $this->workspace->id, + ]), + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->assertSuccessful(); + + expect($this->post->fresh()->status)->toBe(PostStatus::Publishing) + ->and($failedPlatform->fresh()->status)->toBe(PlatformStatus::Pending); + + Bus::assertDispatched(PublishToSocialPlatform::class, fn (PublishToSocialPlatform $job): bool => $job->postPlatform->is($failedPlatform)); +}); + +test('it fails when no failed enabled platform matches', function () { + Bus::fake([PublishToSocialPlatform::class]); + PostPlatform::factory()->published()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $this->workspace->id, + ]), + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsOutput('No failed enabled platforms matched this post.') + ->assertFailed(); + + expect($this->post->fresh()->status)->toBe(PostStatus::PartiallyPublished); + Bus::assertNotDispatched(PublishToSocialPlatform::class); +}); + +test('it fails when the post does not exist', function () { + Bus::fake([PublishToSocialPlatform::class]); + + $this->artisan('posts:retry', ['post' => '019ff9ae-068b-72bf-9f2e-0314ce7dc0e2']) + ->expectsOutput('Post not found.') + ->assertFailed(); + + Bus::assertNotDispatched(PublishToSocialPlatform::class); +}); + +test('a TikTok retry with a publish_id resumes instead of calling init', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ]], + ]); + + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDay(), + ]), + 'error_context' => [ + 'tiktok_publish_id' => 'pub_existing', + 'retry_count' => 120, + 'max_retries' => 120, + 'category' => 'platform_unavailable', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->assertSuccessful(); + + $api = config('trypost.platforms.tiktok.api'); + Http::fake([ + $api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => ['video_123'], + ], + ]), + ]); + + (new PublishToSocialPlatform($failedTikTok->fresh()))->handle(); + + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedTikTok->fresh()->platform_post_id)->toBe('video_123'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); +}); + +test('an Instagram retry with a workflow resumes instead of creating a container', function () { + $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', + ]], + ]); + + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123456789', + 'token_expires_at' => now()->addDays(60), + ]), + 'content_type' => ContentType::InstagramFeed, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + 'retry_count' => 90, + 'max_retries' => 90, + 'category' => 'platform_unavailable', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->assertSuccessful(); + + Http::fake([ + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-123456789'], 200), + 'https://graph.instagram.com/v25.0/media-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/ABC123/', + ], 200), + ]); + + (new PublishToSocialPlatform($failedInstagram->fresh()))->handle(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedInstagram->fresh()->platform_post_id)->toBe('media-123456789'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST' && str_ends_with($request->url(), '/ig_123456789/media')); +}); + +test('it treats an empty TikTok publish_id as a new attempt', function () { + Bus::fake([PublishToSocialPlatform::class]); + Storage::fake(); + + $derivativePath = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($derivativePath, 'temporary image'); + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'tiktok_publish_id' => '', + 'tiktok_derivative_paths' => [$derivativePath], + 'category' => 'unknown', + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + Storage::assertMissing($derivativePath); + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedTikTok->fresh()->error_context)->toBeNull(); +}); + +test('it treats an empty Instagram workflow as a new attempt', function () { + Bus::fake([PublishToSocialPlatform::class]); + + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'instagram_workflow' => [], + 'category' => 'unknown', + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedInstagram->fresh()->error_context)->toBeNull(); +}); + +test('it keeps TikTok and Instagram checkpoints independently on the same post', function () { + Bus::fake([PublishToSocialPlatform::class]); + + $workflow = [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ]; + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'tiktok_publish_id' => 'pub_existing', + 'retry_count' => 12, + 'category' => 'platform_unavailable', + ], + ]); + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => [ + 'instagram_workflow' => $workflow, + 'retry_count' => 8, + 'category' => 'timeout', + ], + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + expect($failedTikTok->fresh()->error_context)->toBe([ + 'tiktok_publish_id' => 'pub_existing', + ])->and($failedInstagram->fresh()->error_context)->toBe([ + 'instagram_workflow' => $workflow, + ]); +}); + +test('it starts over when the failure category is not resumable', function (?string $category) { + Bus::fake([PublishToSocialPlatform::class]); + + $errorContext = ['tiktok_publish_id' => 'pub_dead']; + + if ($category !== null) { + $errorContext['category'] = $category; + } + + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]), + 'error_context' => $errorContext, + ]); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Pending) + ->and($failedTikTok->fresh()->error_context)->toBeNull(); +})->with([ + 'media format' => ['media_format'], + 'content policy' => ['content_policy'], + 'server error' => ['server_error'], + 'permission' => ['permission'], + 'rate limit' => ['rate_limit'], + 'unknown' => ['unknown'], + 'missing category' => [null], +]); + +test('a TikTok retry after a remote FAILED starts a new publish', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ]], + ]); + + $failedTikTok = PostPlatform::factory()->tiktok()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDay(), + ]), + 'error_context' => [ + 'tiktok_publish_id' => 'pub_dead', + 'category' => 'media_format', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + expect($failedTikTok->fresh()->error_context)->toBeNull(); + + $api = config('trypost.platforms.tiktok.api'); + Http::fake([ + $api.'/post/publish/video/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_fresh'], + ], 200), + $api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => ['video_456'], + ], + ]), + ]); + + (new PublishToSocialPlatform($failedTikTok->fresh()))->handle(); + + expect($failedTikTok->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedTikTok->fresh()->platform_post_id)->toBe('video_456'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/init/')); +}); + +test('an Instagram retry after a container ERROR starts a new container', function () { + $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', + ]], + ]); + + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123456789', + 'token_expires_at' => now()->addDays(60), + ]), + 'content_type' => ContentType::InstagramFeed, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-dead', + ], + 'category' => 'server_error', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + expect($failedInstagram->fresh()->error_context)->toBeNull(); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-fresh'], 200), + 'https://graph.instagram.com/v25.0/container-fresh*' => Http::response(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-456'], 200), + 'https://graph.instagram.com/v25.0/media-456*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/DEF456/', + ], 200), + ]); + + (new PublishToSocialPlatform($failedInstagram->fresh()))->handle(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedInstagram->fresh()->platform_post_id)->toBe('media-456'); + + Http::assertSent(fn ($request) => $request->method() === 'POST' && str_ends_with($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'container-dead')); +}); + +test('an Instagram retry after a container EXPIRED starts a new container', function () { + $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', + ]], + ]); + + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123456789', + 'token_expires_at' => now()->addDays(60), + ]), + 'content_type' => ContentType::InstagramFeed, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-expired', + ], + 'category' => 'platform_unavailable', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + Http::fake([ + 'https://graph.instagram.com/v25.0/container-expired*' => Http::response(['status_code' => 'EXPIRED'], 200), + ]); + + (new PublishToSocialPlatform($failedInstagram->fresh()))->handle(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Failed) + ->and($failedInstagram->fresh()->error_context['category'] ?? null)->toBe('server_error'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('New') + ->assertSuccessful(); + + expect($failedInstagram->fresh()->error_context)->toBeNull(); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-fresh'], 200), + 'https://graph.instagram.com/v25.0/container-fresh*' => Http::response(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-789'], 200), + 'https://graph.instagram.com/v25.0/media-789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/GHI789/', + ], 200), + ]); + + (new PublishToSocialPlatform($failedInstagram->fresh()))->handle(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedInstagram->fresh()->platform_post_id)->toBe('media-789'); + + Http::assertSent(fn ($request) => $request->method() === 'POST' && str_ends_with($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'container-expired')); +}); + +test('an Instagram resume of a published container completes without media_publish', function () { + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123456789', + 'token_expires_at' => now()->addDays(60), + ]), + 'content_type' => ContentType::InstagramFeed, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + 'category' => 'platform_unavailable', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) { + return Http::response(['status_code' => 'PUBLISHED'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-account-post', + 'permalink' => 'https://www.instagram.com/p/WRONG/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + (new PublishToSocialPlatform($failedInstagram->fresh()))->handle(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedInstagram->fresh()->platform_post_id)->toBe('container-123') + ->and($failedInstagram->fresh()->platform_url)->toBeNull(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +test('an Instagram resume of a checkpointed media id completes without media_publish', function () { + $failedInstagram = PostPlatform::factory()->instagram()->failed()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123456789', + 'token_expires_at' => now()->addDays(60), + ]), + 'content_type' => ContentType::InstagramFeed, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + 'media_id' => 'media-persisted', + ], + 'category' => 'job_failed', + ], + ]); + + Mail::fake(); + Queue::fake(); + + $this->artisan('posts:retry', ['post' => $this->post->id]) + ->expectsConfirmation('Queue publish attempts for these failed platforms?', 'yes') + ->expectsOutputToContain('Resume') + ->assertSuccessful(); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/media-persisted')) { + return Http::response([ + 'permalink' => 'https://www.instagram.com/p/PERSISTED/', + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + (new PublishToSocialPlatform($failedInstagram->fresh()))->handle(); + + expect($failedInstagram->fresh()->status)->toBe(PlatformStatus::Published) + ->and($failedInstagram->fresh()->platform_post_id)->toBe('media-persisted') + ->and($failedInstagram->fresh()->platform_url)->toBe('https://www.instagram.com/p/PERSISTED/'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/container-123')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 97d51621..16ef42bb 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -21,17 +21,21 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\ConnectionVerifier; use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; use App\Services\Social\PinterestPublisher; use Carbon\Carbon; use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Sleep; beforeEach(function () { @@ -263,7 +267,11 @@ $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( - new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) + new PlatformUnavailableException( + 'LinkedIn API returned 503 during token refresh', + 503, + ['operation_id' => 'operation-123'], + ) ); $this->app->instance(LinkedInPublisher::class, $publisher); @@ -277,6 +285,7 @@ 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->postPlatform->error_context['operation_id'] ?? null)->toBe('operation-123'); expect($this->postPlatform->error_message)->toBe(__('posts.errors.platform_unavailable')); expect($this->postPlatform->error_context['detail'] ?? null)->toContain('LinkedIn API returned 503'); expect($this->socialAccount->status)->toBe(AccountStatus::Connected); @@ -398,6 +407,43 @@ Carbon::setTestNow(); }); +test('publish honors a platform-specific retry delay and retry limit', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $now = now()->startOfSecond(); + Carbon::setTestNow($now); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new PlatformUnavailableException( + message: 'Remote operation is still processing', + context: ['operation_id' => 'operation-123'], + retryDelaySeconds: 30, + maxRetries: 2, + )); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + + expect($this->postPlatform->error_context['next_attempt_at'] ?? null) + ->toBe($now->copy()->addSeconds(30)->toIso8601String()); + + Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) use ($now) { + return $job->delay instanceof DateTimeInterface + && Carbon::instance($job->delay)->equalTo($now->copy()->addSeconds(30)); + }); + + $this->postPlatform->update(['error_context' => ['retry_count' => 2]]); + (new PublishToSocialPlatform($this->postPlatform->fresh()))->handle(); + + expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Failed); + + Carbon::setTestNow(); +}); + test('publish records last_attempt_at when rescheduling for retry', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); @@ -422,6 +468,72 @@ Carbon::setTestNow(); }); +test('publish preserves resumable context when a later transient error has no context', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $checkpoint = [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + 'tiktok_publish_id' => 'publish-123', + 'tiktok_derivative_paths' => ['social-tiktok-photos/pending.jpg'], + 'retry_count' => 2, + ]; + $this->postPlatform->update(['error_context' => $checkpoint]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('Token refresh service unavailable', 503) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform->fresh()))->handle(); + + $context = $this->postPlatform->fresh()->error_context; + + expect($context['instagram_workflow'] ?? null)->toBe($checkpoint['instagram_workflow']) + ->and($context['tiktok_publish_id'] ?? null)->toBe('publish-123') + ->and($context['tiktok_derivative_paths'] ?? null)->toBe(['social-tiktok-photos/pending.jpg']) + ->and($context['retry_count'] ?? null)->toBe(3) + ->and($context['http_status'] ?? null)->toBe(503); +}); + +test('publish preserves a resumable retry policy after the global retry limit', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + 'retry_count' => 7, + 'max_retries' => 90, + 'retry_delay_seconds' => 10, + ], + ]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('Token refresh service unavailable', 503) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform->fresh()))->handle(); + + $context = $this->postPlatform->fresh()->error_context; + + expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Retrying) + ->and($context['retry_count'] ?? null)->toBe(8) + ->and($context['max_retries'] ?? null)->toBe(90) + ->and($context['retry_delay_seconds'] ?? null)->toBe(10); +}); + test('post stays in Publishing while one platform is still Retrying', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); @@ -533,6 +645,41 @@ Bus::assertNotDispatched(PublishToSocialPlatform::class); }); +test('publish keeps a resumable checkpoint when platform unavailable retries are exhausted', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $workflow = [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ]; + $this->postPlatform->update([ + 'error_context' => [ + 'tiktok_publish_id' => 'pub_in_flight', + 'instagram_workflow' => $workflow, + 'retry_count' => PublishToSocialPlatform::MAX_PLATFORM_UNAVAILABLE_RETRIES, + ], + ]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn 503', 503) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform->fresh()))->handle(); + + $context = $this->postPlatform->fresh()->error_context; + + expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Failed) + ->and($context['tiktok_publish_id'] ?? null)->toBe('pub_in_flight') + ->and($context['instagram_workflow'] ?? null)->toBe($workflow) + ->and($context['category'] ?? null)->toBe('platform_unavailable'); + + Bus::assertNotDispatched(PublishToSocialPlatform::class); +}); + test('publish skips platforms that are already failed', function () { Event::fake(); Mail::fake(); @@ -576,6 +723,48 @@ ->and($job->uniqueFor)->toBe(960); }); +test('publish job prevents concurrent execution across different attempts', function () { + $job = new PublishToSocialPlatform($this->postPlatform, 3); + $middleware = $job->middleware(); + + expect($middleware)->toHaveCount(1) + ->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class) + ->and($middleware[0]->key)->toBe("social-publish:{$this->postPlatform->id}") + ->and($middleware[0]->releaseAfter)->toBe(60) + ->and($middleware[0]->expiresAfter)->toBe($job->timeout + 60) + ->and($job->tries)->toBe(20) + ->and($job->maxExceptions)->toBe(1); +}); + +test('publish job releases an overlapping execution for the same platform', function () { + $runningJob = new PublishToSocialPlatform($this->postPlatform, 0); + $overlappingJob = (new PublishToSocialPlatform($this->postPlatform, 1))->withFakeQueueInteractions(); + /** @var WithoutOverlapping $middleware */ + $middleware = $overlappingJob->middleware()[0]; + $lock = Cache::lock($middleware->getLockKey($runningJob), $runningJob->timeout + 60); + $handled = false; + + expect($middleware->getLockKey($runningJob))->toBe($middleware->getLockKey($overlappingJob)) + ->and($lock->get())->toBeTrue(); + + try { + $middleware->handle($overlappingJob, function () use (&$handled): void { + $handled = true; + }); + } finally { + $lock->release(); + } + + expect($handled)->toBeFalse(); + $overlappingJob->assertReleased(60); + + $middleware->handle($overlappingJob, function () use (&$handled): void { + $handled = true; + }); + + expect($handled)->toBeTrue(); +}); + test('publish job unique lock drops a duplicate dispatch for the same platform attempt', function () { Bus::fake([PublishToSocialPlatform::class]); @@ -634,6 +823,226 @@ ->and($this->postPlatform->error_message)->toBeNull(); }); +test('failed hook keeps TikTok photo derivatives while a publish_id can be resumed', function () { + Event::fake(); + Mail::fake(); + Storage::fake(); + + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + $unrelatedPath = 'customer-media/keep.jpg'; + Storage::put($path, 'image'); + Storage::put($unrelatedPath, 'image'); + $this->postPlatform->update([ + 'platform' => Platform::TikTok, + 'status' => PlatformStatus::Retrying, + 'error_context' => [ + 'tiktok_publish_id' => 'publish-123', + 'tiktok_derivative_paths' => [$path, 'social-tiktok-photos/../customer-media/keep.jpg'], + ], + ]); + + (new PublishToSocialPlatform($this->postPlatform->fresh()))->failed(new TypeError('Simulated worker kill')); + + Storage::assertExists($path); + Storage::assertExists($unrelatedPath); + expect($this->postPlatform->fresh()->error_context)->toMatchArray([ + 'tiktok_publish_id' => 'publish-123', + 'category' => 'job_failed', + ]); +}); + +test('failed hook prunes TikTok photo derivatives when there is no publish_id', function () { + Event::fake(); + Mail::fake(); + Storage::fake(); + + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($path, 'image'); + $this->postPlatform->update([ + 'platform' => Platform::TikTok, + 'status' => PlatformStatus::Retrying, + 'error_context' => [ + 'tiktok_derivative_paths' => [$path], + ], + ]); + + (new PublishToSocialPlatform($this->postPlatform->fresh()))->failed(new TypeError('Simulated worker kill')); + + Storage::assertMissing($path); + expect($this->postPlatform->fresh()->error_context['category'] ?? null)->toBe('job_failed'); +}); + +test('terminal TikTok account guards keep derivatives while a publish_id can be resumed', function (string $guard) { + Event::fake(); + Mail::fake(); + Storage::fake(); + + $path = 'social-tiktok-photos/'.fake()->uuid().'.jpg'; + Storage::put($path, 'image'); + + $accountAttributes = match ($guard) { + 'inactive' => ['is_active' => false], + 'disconnected' => ['status' => AccountStatus::Disconnected], + 'token_expired' => ['status' => AccountStatus::TokenExpired], + 'missing_scopes' => ['scopes' => []], + }; + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ...$accountAttributes, + ]); + $platform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'status' => PlatformStatus::Retrying, + 'error_context' => [ + 'tiktok_publish_id' => "publish-{$guard}", + 'tiktok_derivative_paths' => [$path], + ], + ]); + + (new PublishToSocialPlatform($platform))->handle(); + + Storage::assertExists($path); + $platform->refresh(); + + expect($platform->status)->toBe(PlatformStatus::Failed) + ->and($platform->error_context['tiktok_publish_id'] ?? null)->toBe("publish-{$guard}"); + + if ($guard === 'missing_scopes') { + expect($platform->error_message)->toBe('Missing permissions: video.publish. Please reconnect your account.') + ->and($platform->error_context['category'] ?? null)->toBe('permission') + ->and($platform->error_context['missing_scopes'] ?? null)->toBe(['video.publish']); + + return; + } + + $translationKey = match ($guard) { + 'inactive' => 'posts.errors.account_inactive', + 'disconnected' => 'posts.errors.account_disconnected', + 'token_expired' => 'posts.errors.account_token_expired', + }; + + expect($platform->error_message)->toBe(__($translationKey)); +})->with([ + 'inactive account' => 'inactive', + 'disconnected account' => 'disconnected', + 'expired token' => 'token_expired', + 'missing publish scopes' => 'missing_scopes', +]); + +test('terminal TikTok account guards prune derivatives when there is no publish_id', function (string $guard) { + Event::fake(); + Mail::fake(); + Storage::fake(); + + $path = 'social-tiktok-photos/'.fake()->uuid().'.jpg'; + Storage::put($path, 'image'); + + $accountAttributes = match ($guard) { + 'inactive' => ['is_active' => false], + 'disconnected' => ['status' => AccountStatus::Disconnected], + 'token_expired' => ['status' => AccountStatus::TokenExpired], + 'missing_scopes' => ['scopes' => []], + }; + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ...$accountAttributes, + ]); + $platform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'status' => PlatformStatus::Retrying, + 'error_context' => [ + 'tiktok_derivative_paths' => [$path], + ], + ]); + + (new PublishToSocialPlatform($platform))->handle(); + + Storage::assertMissing($path); + expect($platform->fresh()->status)->toBe(PlatformStatus::Failed) + ->and($platform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBeNull(); +})->with([ + 'inactive account' => 'inactive', + 'disconnected account' => 'disconnected', + 'expired token' => 'token_expired', + 'missing publish scopes' => 'missing_scopes', +]); + +test('tiktok photo publish resumes after a status-fetch token expiry without a second init', function () { + Event::fake(); + Mail::fake(); + Storage::fake(); + + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDay(), + ]); + $this->post->update([ + 'media' => [[ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ]], + ]); + $platform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'status' => PlatformStatus::Pending, + 'enabled' => true, + 'meta' => ['privacy_level' => 'SELF_ONLY'], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + $verifier = Mockery::mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->once()->andReturn(true); + $this->app->instance(ConnectionVerifier::class, $verifier); + + $api = config('trypost.platforms.tiktok.api'); + + Http::fake([ + $api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_job_401']]), + $api.'/post/publish/status/fetch/' => Http::sequence() + ->push([ + 'error' => [ + 'code' => 'access_token_invalid', + 'message' => 'Access token is invalid', + ], + ], 401) + ->push([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => ['video_123'], + ], + ]), + '*' => Http::response('fake-image-content', 200), + ]); + + (new PublishToSocialPlatform($platform))->handle(); + + $platform->refresh(); + + expect($platform->status)->toBe(PlatformStatus::Published) + ->and($platform->platform_post_id)->toBe('video_123') + ->and($platform->error_context)->toBeNull() + ->and(Storage::allFiles('social-tiktok-photos'))->toBeEmpty() + ->and(Http::recorded(fn ($request) => str_contains($request->url(), '/post/publish/content/init/'))) + ->toHaveCount(1); +}); + test('pinterest media status 401 marks the account token expired and notifies to reconnect', function () { Event::fake(); Queue::fake(); @@ -990,6 +1399,41 @@ expect($this->postPlatform->error_context['raw_response'])->toBe('{"error": "forbidden"}'); }); +test('publish keeps a resumable checkpoint when a later publish exception is terminal', function () { + Event::fake(); + + $workflow = [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ]; + $this->postPlatform->update([ + 'error_context' => [ + 'tiktok_publish_id' => 'pub_dead', + 'instagram_workflow' => $workflow, + ], + ]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new LinkedInPublishException( + 'Video rejected', + ErrorCategory::ContentPolicy, + 'video_rejected', + '{"status":"FAILED"}', + ) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform->fresh()))->handle(); + + $context = $this->postPlatform->fresh()->error_context; + + expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Failed) + ->and($context['tiktok_publish_id'] ?? null)->toBe('pub_dead') + ->and($context['instagram_workflow'] ?? null)->toBe($workflow) + ->and($context['category'] ?? null)->toBe('content_policy'); +}); + test('publish to social platform fails when scopes are missing', function () { Event::fake(); diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 990a6fa5..752627bb 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -16,6 +16,7 @@ use App\Services\Social\LinkCard\LinkCardMetadata; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Sleep; beforeEach(function () { $this->user = User::factory()->create(); @@ -1050,6 +1051,114 @@ function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); }); +test('bluesky publisher backs off while video processing remains pending', function () { + attachBlueskyVideo($this->post); + config([ + 'trypost.platforms.bluesky.video_poll_seconds' => 2, + 'trypost.platforms.bluesky.video_poll_max_seconds' => 30, + ]); + Sleep::fake(); + + $statusChecks = 0; + Http::fake(function ($request) use (&$statusChecks) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]]); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token']); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-backoff', 'state' => 'JOB_STATE_CREATED']); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + $statusChecks++; + + if ($statusChecks === 8) { + return Http::response(['jobStatus' => [ + 'jobId' => 'job-backoff', + 'state' => 'JOB_STATE_COMPLETED', + 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafbackoff'], 'mimeType' => 'video/mp4', 'size' => 2048], + ]]); + } + + return Http::response(['jobStatus' => ['jobId' => 'job-backoff', 'state' => 'JOB_STATE_RUNNING']]); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3backoff', 'cid' => 'bafbackoff']); + } + + return Http::response(str_repeat('v', 2048)); + }); + + $this->publisher->publish($this->postPlatform); + + Sleep::assertSequence([ + Sleep::for(2)->seconds(), + Sleep::for(2)->seconds(), + Sleep::for(2)->seconds(), + Sleep::for(4)->seconds(), + Sleep::for(4)->seconds(), + Sleep::for(4)->seconds(), + Sleep::for(8)->seconds(), + ]); +}); + +test('bluesky publisher caps video poll backoff at the configured maximum', function () { + attachBlueskyVideo($this->post); + config([ + 'trypost.platforms.bluesky.video_poll_seconds' => 10, + 'trypost.platforms.bluesky.video_poll_max_seconds' => 30, + ]); + Sleep::fake(); + + $statusChecks = 0; + Http::fake(function ($request) use (&$statusChecks) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]]); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token']); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-cap', 'state' => 'JOB_STATE_CREATED']); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + $statusChecks++; + + if ($statusChecks === 8) { + return Http::response(['jobStatus' => [ + 'jobId' => 'job-cap', + 'state' => 'JOB_STATE_COMPLETED', + 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafcap'], 'mimeType' => 'video/mp4', 'size' => 2048], + ]]); + } + + return Http::response(['jobStatus' => ['jobId' => 'job-cap', 'state' => 'JOB_STATE_RUNNING']]); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3cap', 'cid' => 'bafcap']); + } + + return Http::response(str_repeat('v', 2048)); + }); + + $this->publisher->publish($this->postPlatform); + + Sleep::assertSequence([ + Sleep::for(10)->seconds(), + Sleep::for(10)->seconds(), + Sleep::for(10)->seconds(), + Sleep::for(20)->seconds(), + Sleep::for(20)->seconds(), + Sleep::for(20)->seconds(), + Sleep::for(30)->seconds(), + ]); +}); + test('bluesky publisher retries the upload up to three times before giving up', function () { attachBlueskyVideo($this->post); config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 68a5fbeb..dc50ba50 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -4,6 +4,8 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; +use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; @@ -13,6 +15,8 @@ use App\Models\Workspace; use App\Services\Media\MediaOptimizer; use App\Services\Social\InstagramPublisher; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Intervention\Image\Drivers\Gd\Driver; @@ -441,6 +445,69 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string expect($result['id'])->toBe('carousel-mix-123456789'); }); +test('instagram publisher resumes a processing carousel child without recreating child containers', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-image', + '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', + ], + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test.mp4', + ], + ], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::sequence() + ->push(['id' => 'child-1'], 200) + ->push(['id' => 'child-2'], 200) + ->push(['id' => 'carousel-container-123'], 200), + 'https://graph.instagram.com/v25.0/child-2*' => Http::sequence() + ->push(['status_code' => 'IN_PROGRESS'], 200) + ->push(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v25.0/carousel-container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'carousel-resumed-123456789', + ], 200), + 'https://graph.instagram.com/v25.0/carousel-resumed-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/CAROUSELRESUMED/', + ], 200), + ]); + + try { + $this->publisher->publish($this->postPlatform); + test()->fail('Expected the processing carousel child to be rescheduled.'); + } catch (PlatformUnavailableException $exception) { + expect($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'carousel_children', + 'child_container_ids' => ['child-1', 'child-2'], + 'processing_child_container_ids' => ['child-2'], + ], + ])->and($exception->retryDelaySeconds)->toBe(10) + ->and($exception->maxRetries)->toBe(90); + + $this->postPlatform->update(['error_context' => $exception->context]); + } + + $result = $this->publisher->publish($this->postPlatform->fresh()); + + expect($result['id'])->toBe('carousel-resumed-123456789') + ->and(collect(Http::recorded())->filter( + fn (array $pair) => $pair[0]->method() === 'POST' && str_ends_with($pair[0]->url(), '/ig_123456789/media') + ))->toHaveCount(3); +}); + test('instagram publisher throws exception on api error', function () { $this->post->update([ 'media' => [ @@ -592,7 +659,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string ->toThrow(Exception::class, 'Instagram media processing failed'); }); -test('instagram publisher waits for media processing', function () { +test('instagram publisher resumes media processing without creating another container', function () { $this->post->update([ 'media' => [ [ @@ -621,9 +688,707 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string ], 200), ]); - $result = $this->publisher->publish($this->postPlatform); + try { + $this->publisher->publish($this->postPlatform); + test()->fail('Expected the in-progress container to be rescheduled.'); + } catch (PlatformUnavailableException $exception) { + expect($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ])->and($exception->retryDelaySeconds)->toBe(10) + ->and($exception->maxRetries)->toBe(90); + + $this->postPlatform->update(['error_context' => $exception->context]); + } + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(PlatformUnavailableException::class); + + $result = $this->publisher->publish($this->postPlatform->fresh()); expect($result['id'])->toBe('media-123456789'); + + Http::assertSentCount(6); + expect(collect(Http::recorded())->filter( + fn (array $pair) => $pair[0]->method() === 'POST' && str_ends_with($pair[0]->url(), '/ig_123456789/media') + ))->toHaveCount(1); +}); + +test('instagram publisher does not publish a container that never finishes processing', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'IN_PROGRESS']), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher retries a transient Graph rate-limit on container status', function (int $code) { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response([ + 'error' => [ + 'message' => 'Instagram Platform rate limit reached.', + 'code' => $code, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->httpStatus)->toBe(400) + ->and($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); +})->with([ + 'app rate limit' => [4], + 'user rate limit' => [17], + 'instagram buc' => [80002], +]); + +test('instagram publisher retries a transient Graph failure on media_publish', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED']), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([ + 'error' => [ + 'message' => 'An unexpected error has occurred. Please retry your request later.', + 'type' => 'OAuthException', + 'is_transient' => true, + 'code' => 2, + ], + ], 500), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->httpStatus)->toBe(500) + ->and($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); +}); + +test('instagram publisher retries a dropped connection on media_publish', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED']), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); +}); + +test('instagram publisher retries a dropped connection on container status', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher retries a dropped connection on container create', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->getMessage())->toContain('Instagram API unreachable') + ->and($exception->context)->toBe([]); + }); +}); + +test('instagram publisher keeps a published media id when the permalink request drops', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED']), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-123456789']), + 'https://graph.instagram.com/v25.0/media-123456789*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + expect($this->publisher->publish($this->postPlatform))->toBe([ + 'id' => 'media-123456789', + 'url' => null, + ]); + + expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([ + 'stage' => 'final_container', + 'container_id' => 'container-123', + 'media_id' => 'media-123456789', + ]); +}); + +test('instagram publisher does not publish again when a transient media_publish already landed', function () { + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'PUBLISHED']), + ]); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'container-123', + 'url' => null, + ]); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher fails a confirmed container status rejection', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response([ + 'error' => [ + 'message' => 'The requested resource does not exist', + 'type' => 'OAuthException', + 'code' => 100, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(InstagramPublishException::class); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher rejects an invalid workflow instead of starting over', function (array $workflow) { + $this->postPlatform->update([ + 'error_context' => ['instagram_workflow' => $workflow], + ]); + + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(InstagramPublishException::class, 'Instagram publish state is invalid and cannot be resumed.'); + + Http::assertNothingSent(); +})->with([ + 'unknown stage' => [['stage' => 'unknown', 'container_id' => 'container-123']], + 'final container without id' => [['stage' => 'final_container']], + 'carousel without children' => [['stage' => 'carousel_children', 'child_container_ids' => []]], +]); + +test('instagram publisher fails a resumed container that reports ERROR', function () { + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'ERROR'], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(InstagramPublishException::class, 'Instagram media processing failed'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); + Http::assertNotSent(fn ($request) => $request->method() === 'POST' && str_contains($request->url(), '/media')); +}); + +test('instagram publisher fails an expired container instead of retrying it', function () { + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'EXPIRED'], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(function (InstagramPublishException $exception): void { + expect($exception->userMessage)->toBe('Media container expired. Please try again in a few minutes.') + ->and($exception->category)->toBe(ErrorCategory::ServerError); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); + Http::assertNotSent(fn ($request) => $request->method() === 'POST' && str_contains($request->url(), '/media')); +}); + +test('instagram publisher completes a published container without calling media_publish', function () { + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) { + return Http::response(['status_code' => 'PUBLISHED'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-account-post', + 'permalink' => 'https://www.instagram.com/p/WRONG/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'container-123', + 'url' => null, + ]); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +test('instagram publisher does not bind another account post when a published story has no media id', function () { + $this->postPlatform->update([ + 'content_type' => ContentType::InstagramStory, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) { + return Http::response(['status_code' => 'PUBLISHED'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/stories')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-story', + 'permalink' => 'https://www.instagram.com/stories/testuser/1/', + ]], + ], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'feed-must-not-be-used', + 'permalink' => 'https://www.instagram.com/p/FEED/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'container-123', + 'url' => null, + ]); + + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/stories')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher does not bind another reel from /media when published without a media id', function () { + $this->postPlatform->update([ + 'content_type' => ContentType::InstagramReel, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) { + return Http::response(['status_code' => 'PUBLISHED'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-reel', + 'permalink' => 'https://www.instagram.com/reel/WRONG/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'container-123', + 'url' => null, + ]); + + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/stories')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher keeps a published carousel parent id without listing /media', function () { + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'carousel_children', + 'child_container_ids' => ['child-1', 'child-2'], + 'processing_child_container_ids' => ['child-2'], + ], + ], + ]); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/child-2')) { + return Http::response(['status_code' => 'FINISHED'], 200); + } + + if ($request->method() === 'POST' && str_ends_with(explode('?', $request->url())[0], '/ig_123456789/media')) { + return Http::response(['id' => 'carousel-parent-123'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/carousel-parent-123')) { + return Http::response(['status_code' => 'PUBLISHED'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-carousel', + 'permalink' => 'https://www.instagram.com/p/WRONG/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'carousel-parent-123', + 'url' => null, + ]); + + Http::assertSent(fn (Request $request) => $request->method() === 'POST' && str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn (Request $request) => $request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher resumes a checkpointed media id without listing recent media', function () { + $this->postPlatform->update([ + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + 'media_id' => 'media-persisted', + ], + ], + ]); + + Http::fake(function (Request $request) { + if ($request->method() === 'GET' && str_contains($request->url(), '/media-persisted')) { + return Http::response([ + 'permalink' => 'https://www.instagram.com/p/PERSISTED/', + ], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-account-post', + 'permalink' => 'https://www.instagram.com/p/WRONG/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'media-persisted', + 'url' => 'https://www.instagram.com/p/PERSISTED/', + ]); + + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/container-123')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/media')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher checkpoints the media id before fetching the permalink', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-123456789'], 200), + 'https://graph.instagram.com/v25.0/media-123456789*' => Http::response(['error' => ['message' => 'temporarily unavailable']], 500), + ]); + + expect($this->publisher->publish($this->postPlatform))->toBe([ + 'id' => 'media-123456789', + 'url' => null, + ]); + + expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([ + 'stage' => 'final_container', + 'container_id' => 'container-123', + 'media_id' => 'media-123456789', + ]); +}); + +test('instagram facebook publisher recovers a published container on graph.facebook.com', function () { + $account = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::InstagramFacebook, + 'platform_user_id' => 'ig_fb_123', + 'access_token' => 'page_token_123', + 'token_expires_at' => null, + 'scopes' => Platform::InstagramFacebook->requiredPublishScopes(), + ]); + $this->postPlatform->update([ + 'social_account_id' => $account->id, + 'platform' => Platform::InstagramFacebook, + 'error_context' => [ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ], + ]); + + $graph = (string) config('trypost.platforms.instagram-facebook.graph_api'); + + Http::fake(function (Request $request) use ($graph) { + expect($request->url())->toStartWith($graph) + ->and($request->url())->not->toContain('graph.instagram.com'); + + if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) { + return Http::response(['status_code' => 'PUBLISHED'], 200); + } + + if ($request->method() === 'GET' && str_contains($request->url(), '/ig_fb_123/media')) { + return Http::response([ + 'data' => [[ + 'id' => 'other-facebook-post', + 'permalink' => 'https://www.instagram.com/p/WRONG/', + ]], + ], 200); + } + + return Http::response(['error' => ['message' => 'unexpected']], 500); + }); + + expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([ + 'id' => 'container-123', + 'url' => null, + ]); + + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), 'graph.instagram.com')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_fb_123/media')); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher retries a 5xx on container status without publishing', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['error' => ['message' => 'Service temporarily unavailable']], 503), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->httpStatus)->toBe(503) + ->and($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); +}); + +test('instagram publisher retries a 429 on container status without publishing', function () { + $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', + ]], + ]); + + Http::fake([ + 'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']), + 'https://graph.instagram.com/v25.0/container-123*' => Http::response(['error' => ['message' => 'Application request limit reached', 'code' => 4]], 429), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->httpStatus)->toBe(429) + ->and($exception->context)->toBe([ + 'instagram_workflow' => [ + 'stage' => 'final_container', + 'container_id' => 'container-123', + ], + ]); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish')); }); test('instagram publisher throws exception when all carousel items fail', function () { diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index d05ca5ed..49b728bb 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\TikTokPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; @@ -87,6 +88,340 @@ }); }); +test('tiktok publisher does not report success before processing completes', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ]], + ]); + + Http::fake([ + $this->api.'/post/publish/video/init/' => Http::response(['data' => ['publish_id' => 'pub_processing']]), + $this->api.'/post/publish/status/fetch/' => Http::response(['data' => ['status' => 'PROCESSING_DOWNLOAD']]), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->context)->toBe(['tiktok_publish_id' => 'pub_processing']) + ->and($exception->retryDelaySeconds)->toBe(30) + ->and($exception->maxRetries)->toBe(120); + }); + + expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_processing'); + + Http::assertSentCount(2); +}); + +test('tiktok publisher checkpoints a video publish_id when status fetch reports an expired token', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ]], + ]); + + Http::fake([ + $this->api.'/post/publish/video/init/' => Http::response(['data' => ['publish_id' => 'pub_video_401']]), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'error' => [ + 'code' => 'access_token_invalid', + 'message' => 'Access token is invalid', + ], + ], 401), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class); + + expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_video_401'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/content/init/')); +}); + +test('tiktok publisher checkpoints a photo publish_id before polling status', function () { + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-image', + 'path' => 'media/2026-01/image1.jpg', + 'url' => 'https://example.com/media/2026-01/image1.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image1.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], + ]], + ]); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_processing']]), + $this->api.'/post/publish/status/fetch/' => Http::response(['data' => ['status' => 'PROCESSING_DOWNLOAD']]), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(PlatformUnavailableException::class); + + expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_photo_processing'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/init/')); +}); + +test('tiktok publisher checkpoints photo derivatives with the publish_id before polling', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [[ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ]], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_deriv']]), + $this->api.'/post/publish/status/fetch/' => Http::response(['data' => ['status' => 'PROCESSING_DOWNLOAD']]), + '*' => Http::response('fake-image-content', 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(PlatformUnavailableException::class); + + $context = $this->postPlatform->fresh()->error_context; + $paths = $context['tiktok_derivative_paths'] ?? null; + + expect($context['tiktok_publish_id'] ?? null)->toBe('pub_photo_deriv') + ->and($paths)->toBeArray() + ->and($paths)->not->toBeEmpty(); + + foreach ($paths as $path) { + Storage::assertExists($path); + } +}); + +test('tiktok publisher keeps photo derivatives when status fetch reports an expired token', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [[ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ]], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_401']]), + $this->api.'/post/publish/status/fetch/' => Http::sequence() + ->push([ + 'error' => [ + 'code' => 'access_token_invalid', + 'message' => 'Access token is invalid', + ], + ], 401) + ->push([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => ['video_123'], + ], + ]), + '*' => Http::response('fake-image-content', 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class); + + $context = $this->postPlatform->fresh()->error_context; + $paths = $context['tiktok_derivative_paths'] ?? null; + + expect($context['tiktok_publish_id'] ?? null)->toBe('pub_photo_401') + ->and($paths)->toBeArray() + ->and($paths)->not->toBeEmpty(); + + foreach ($paths as $path) { + Storage::assertExists($path); + } + + $result = $this->publisher->publish($this->postPlatform->fresh()); + + expect($result['id'])->toBe('video_123'); + + foreach ($paths as $path) { + Storage::assertMissing($path); + } + + expect(Http::recorded(fn ($request) => str_contains($request->url(), '/post/publish/content/init/'))) + ->toHaveCount(1); +}); + +test('tiktok publisher prunes photo derivatives when TikTok confirms the publish failed', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [[ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ]], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_failed']]), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'FAILED', + 'fail_reason' => 'photo_pull_failed', + ], + ]), + '*' => Http::response('fake-image-content', 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TikTokPublishException::class); + + expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_photo_failed') + ->and(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher resumes an existing publish without creating a duplicate', function () { + $this->postPlatform->update([ + 'error_context' => ['tiktok_publish_id' => 'pub_existing'], + ]); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => ['video_123'], + ], + ]), + ]); + + $result = $this->publisher->publish($this->postPlatform->fresh()); + + expect($result)->toBe([ + 'id' => 'video_123', + 'url' => 'https://www.tiktok.com/@tiktoker/video/video_123', + ]); + + Http::assertSentCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); +}); + +test('tiktok publisher retries a server-error status fetch without creating a duplicate', function () { + $this->postPlatform->update([ + 'error_context' => ['tiktok_publish_id' => 'pub_existing'], + ]); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response(['error' => ['code' => 'internal_error']], 503), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->httpStatus)->toBe(503) + ->and($exception->context['tiktok_publish_id'] ?? null)->toBe('pub_existing'); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); +}); + +test('tiktok publisher retries a rate-limited status fetch without creating a duplicate', function () { + $this->postPlatform->update([ + 'error_context' => ['tiktok_publish_id' => 'pub_existing'], + ]); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response(['error' => ['code' => 'rate_limit']], 429), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->httpStatus)->toBe(429) + ->and($exception->context['tiktok_publish_id'] ?? null)->toBe('pub_existing'); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); +}); + +test('tiktok publisher keeps photo derivatives while pending and prunes them on completion', function () { + Storage::fake(); + $derivativePath = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($derivativePath, 'image'); + + $this->postPlatform->update([ + 'error_context' => [ + 'tiktok_publish_id' => 'pub_existing', + 'tiktok_derivative_paths' => [$derivativePath], + ], + ]); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::sequence() + ->push(['data' => ['status' => 'PROCESSING_DOWNLOAD']]) + ->push(['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['video_123']]]), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform->fresh())) + ->toThrow(function (PlatformUnavailableException $exception) use ($derivativePath): void { + expect($exception->context['tiktok_derivative_paths'] ?? null)->toBe([$derivativePath]); + }); + + Storage::assertExists($derivativePath); + + $result = $this->publisher->publish($this->postPlatform->fresh()); + + expect($result['id'])->toBe('video_123'); + Storage::assertMissing($derivativePath); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); +}); + test('tiktok publisher can publish photos', function () { $this->post->update([ 'media' => [ diff --git a/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php b/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php index 4611ba24..ff9db9e4 100644 --- a/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php @@ -76,11 +76,39 @@ public function platform(): string }); test('error category enum has all expected cases', function () { - expect(ErrorCategory::cases())->toHaveCount(6) + expect(ErrorCategory::cases())->toHaveCount(10) ->and(ErrorCategory::MediaFormat->value)->toBe('media_format') ->and(ErrorCategory::RateLimit->value)->toBe('rate_limit') ->and(ErrorCategory::Permission->value)->toBe('permission') ->and(ErrorCategory::ContentPolicy->value)->toBe('content_policy') ->and(ErrorCategory::ServerError->value)->toBe('server_error') - ->and(ErrorCategory::Unknown->value)->toBe('unknown'); + ->and(ErrorCategory::Unknown->value)->toBe('unknown') + ->and(ErrorCategory::PlatformUnavailable->value)->toBe('platform_unavailable') + ->and(ErrorCategory::Timeout->value)->toBe('timeout') + ->and(ErrorCategory::TokenExpired->value)->toBe('token_expired') + ->and(ErrorCategory::JobFailed->value)->toBe('job_failed'); }); + +test('error category marks only in-flight interruptions as resumable', function (ErrorCategory $category, bool $resumable) { + expect($category->isResumable())->toBe($resumable); +})->with([ + 'platform unavailable' => [ErrorCategory::PlatformUnavailable, true], + 'timeout' => [ErrorCategory::Timeout, true], + 'token expired' => [ErrorCategory::TokenExpired, true], + 'job failed' => [ErrorCategory::JobFailed, true], + 'media format' => [ErrorCategory::MediaFormat, false], + 'content policy' => [ErrorCategory::ContentPolicy, false], + 'server error' => [ErrorCategory::ServerError, false], + 'permission' => [ErrorCategory::Permission, false], + 'rate limit' => [ErrorCategory::RateLimit, false], + 'unknown' => [ErrorCategory::Unknown, false], +]); + +test('error category tryFromContext reads a stored category', function (?array $context, ?ErrorCategory $expected) { + expect(ErrorCategory::tryFromContext($context))->toBe($expected); +})->with([ + 'resumable' => [['category' => 'token_expired'], ErrorCategory::TokenExpired], + 'unknown string' => [['category' => 'not-a-category'], null], + 'missing' => [[], null], + 'null context' => [null, null], +]); diff --git a/tests/Unit/Support/Social/PublishCheckpointTest.php b/tests/Unit/Support/Social/PublishCheckpointTest.php new file mode 100644 index 00000000..3c154fe8 --- /dev/null +++ b/tests/Unit/Support/Social/PublishCheckpointTest.php @@ -0,0 +1,31 @@ + 'pub_in_flight', + ]))->toBe('pub_in_flight') + ->and(PublishCheckpoint::tiktokPublishId(['tiktok_publish_id' => '']))->toBeNull() + ->and(PublishCheckpoint::tiktokPublishId(null))->toBeNull(); +}); + +test('tiktokDerivativePaths returns an array or an empty list', function () { + expect(PublishCheckpoint::tiktokDerivativePaths([ + PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS => ['social-tiktok-photos/a.jpg'], + ]))->toBe(['social-tiktok-photos/a.jpg']) + ->and(PublishCheckpoint::tiktokDerivativePaths(['tiktok_derivative_paths' => 'invalid']))->toBe([]) + ->and(PublishCheckpoint::tiktokDerivativePaths(null))->toBe([]); +}); + +test('instagramWorkflow returns a non-empty array when present', function () { + $workflow = ['stage' => 'final_container', 'container_id' => 'c1']; + + expect(PublishCheckpoint::instagramWorkflow([ + PublishCheckpoint::INSTAGRAM_WORKFLOW => $workflow, + ]))->toBe($workflow) + ->and(PublishCheckpoint::instagramWorkflow(['instagram_workflow' => []]))->toBeNull() + ->and(PublishCheckpoint::instagramWorkflow(null))->toBeNull(); +}); diff --git a/tests/Unit/Support/Social/TikTokPhotoDerivativeCleanerTest.php b/tests/Unit/Support/Social/TikTokPhotoDerivativeCleanerTest.php new file mode 100644 index 00000000..9b71f600 --- /dev/null +++ b/tests/Unit/Support/Social/TikTokPhotoDerivativeCleanerTest.php @@ -0,0 +1,85 @@ +cleanup([ + 'tiktok_derivative_paths' => [...$managedPaths, ...$unmanagedPaths, null, 123], + ]); + + Storage::assertMissing($managedPaths); + Storage::assertExists($unmanagedPaths); +}); + +test('it keeps derivatives while a publish_id is still in flight', function () { + Storage::fake(); + + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($path, 'image'); + + app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight([ + 'tiktok_publish_id' => 'pub_in_flight', + 'tiktok_derivative_paths' => [$path], + ]); + + Storage::assertExists($path); +}); + +test('it prunes derivatives when there is no publish_id to resume', function () { + Storage::fake(); + + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($path, 'image'); + + app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight([ + 'tiktok_derivative_paths' => [$path], + ]); + + Storage::assertMissing($path); +}); + +test('it prunes derivatives when the publish_id is an empty string', function () { + Storage::fake(); + + $path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg'; + Storage::put($path, 'image'); + + app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight([ + 'tiktok_publish_id' => '', + 'tiktok_derivative_paths' => [$path], + ]); + + Storage::assertMissing($path); +}); + +test('it ignores invalid retry context', function () { + Storage::fake(); + + $cleaner = app(TikTokPhotoDerivativeCleaner::class); + + $cleaner->cleanup(null); + $cleaner->cleanup([]); + $cleaner->cleanup(['tiktok_derivative_paths' => 'invalid']); + + Storage::assertDirectoryEmpty('/'); +}); diff --git a/tests/Unit/TikTok/PublishStatusTest.php b/tests/Unit/TikTok/PublishStatusTest.php new file mode 100644 index 00000000..015b063b --- /dev/null +++ b/tests/Unit/TikTok/PublishStatusTest.php @@ -0,0 +1,27 @@ +toHaveCount(5) + ->and(PublishStatus::ProcessingUpload->value)->toBe('PROCESSING_UPLOAD') + ->and(PublishStatus::ProcessingDownload->value)->toBe('PROCESSING_DOWNLOAD') + ->and(PublishStatus::SendToUserInbox->value)->toBe('SEND_TO_USER_INBOX') + ->and(PublishStatus::PublishComplete->value)->toBe('PUBLISH_COMPLETE') + ->and(PublishStatus::Failed->value)->toBe('FAILED'); +}); + +test('publish status tryFrom accepts known values and rejects unknown', function (string $value, ?PublishStatus $expected) { + expect(PublishStatus::tryFrom($value))->toBe($expected); +})->with([ + ['PROCESSING_UPLOAD', PublishStatus::ProcessingUpload], + ['PROCESSING_DOWNLOAD', PublishStatus::ProcessingDownload], + ['SEND_TO_USER_INBOX', PublishStatus::SendToUserInbox], + ['PUBLISH_COMPLETE', PublishStatus::PublishComplete], + ['FAILED', PublishStatus::Failed], + ['PUBLISH_FAILED', null], + ['UNKNOWN', null], + ['', null], +]);