trypost/tests/Feature/Commands/RecoverStuckPostsTest.php

383 lines
13 KiB
PHP
Raw Permalink Normal View History

<?php
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Platform;
fix: Pinterest video processing timeout — longer poll + retry (#246) * fix: give Pinterest video processing more time and retry on timeout A valid ~54s video pin failed after ~90s of polling while Pinterest was still processing. Extend the poll window to ~5 minutes and treat timeout as platform unavailable so PublishToSocialPlatform reschedules instead of failing the post on the first attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: use Laravel Sleep for Pinterest media processing polls Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: inline Pinterest video processing poll constants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: map Pinterest media upload statuses to an enum Use the official MediaUploadStatus values (registered, processing, succeeded, failed) instead of comparing raw strings in the publisher. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: trim Pinterest media processing docblock * fix: cap platform-unavailable retries and recover stuck retrying posts Stop infinite reschedules after 6 attempts with a user-safe failure message, keep technical detail in error_context, recover Retrying platforms in social:recover-stuck-posts, and drop unused isTerminal(). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove unused failedCount in RecoverStuckPosts Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip final Pinterest poll sleep and localize recover timeout Co-authored-by: Cursor <cursoragent@cursor.com> * fix: raise publish job timeout headroom and ignore already-failed platforms Give social publish jobs 15 minutes so Pinterest media polling fits under the worker limit, bump Horizon/redis retry_after above that timeout, and skip handle/failed when the platform is already Failed so delayed jobs cannot revive posts recovered by social:recover-stuck-posts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: restore social-publishing and ai-assistant horizon supervisors Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON Treat media-status 401 as TokenExpired, make PublishToSocialPlatform unique per platform+attempt so retries still queue, and persist recover error_context via Eloquent casts instead of manual json_encode. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: mass-update stuck post platforms without per-row each Eloquent query updates already bind JSON arrays correctly here, so one UPDATE is enough — no manual json_encode and no N model writes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify Pinterest media processing poll loop Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify publish job retry and terminal status checks Co-authored-by: Cursor <cursoragent@cursor.com> * fix: do not finalize posts while platforms are still retrying Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover Pinterest timeout, unique jobs, and recover edge cases Co-authored-by: Cursor <cursoragent@cursor.com> * fix: retry Pinterest media poll on connection errors and tighten tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove ineffective TypeError import that breaks CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
use App\Jobs\PublishToSocialPlatform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
fix: Pinterest video processing timeout — longer poll + retry (#246) * fix: give Pinterest video processing more time and retry on timeout A valid ~54s video pin failed after ~90s of polling while Pinterest was still processing. Extend the poll window to ~5 minutes and treat timeout as platform unavailable so PublishToSocialPlatform reschedules instead of failing the post on the first attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: use Laravel Sleep for Pinterest media processing polls Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: inline Pinterest video processing poll constants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: map Pinterest media upload statuses to an enum Use the official MediaUploadStatus values (registered, processing, succeeded, failed) instead of comparing raw strings in the publisher. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: trim Pinterest media processing docblock * fix: cap platform-unavailable retries and recover stuck retrying posts Stop infinite reschedules after 6 attempts with a user-safe failure message, keep technical detail in error_context, recover Retrying platforms in social:recover-stuck-posts, and drop unused isTerminal(). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove unused failedCount in RecoverStuckPosts Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip final Pinterest poll sleep and localize recover timeout Co-authored-by: Cursor <cursoragent@cursor.com> * fix: raise publish job timeout headroom and ignore already-failed platforms Give social publish jobs 15 minutes so Pinterest media polling fits under the worker limit, bump Horizon/redis retry_after above that timeout, and skip handle/failed when the platform is already Failed so delayed jobs cannot revive posts recovered by social:recover-stuck-posts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: restore social-publishing and ai-assistant horizon supervisors Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON Treat media-status 401 as TokenExpired, make PublishToSocialPlatform unique per platform+attempt so retries still queue, and persist recover error_context via Eloquent casts instead of manual json_encode. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: mass-update stuck post platforms without per-row each Eloquent query updates already bind JSON arrays correctly here, so one UPDATE is enough — no manual json_encode and no N model writes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify Pinterest media processing poll loop Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify publish job retry and terminal status checks Co-authored-by: Cursor <cursoragent@cursor.com> * fix: do not finalize posts while platforms are still retrying Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover Pinterest timeout, unique jobs, and recover edge cases Co-authored-by: Cursor <cursoragent@cursor.com> * fix: retry Pinterest media poll on connection errors and tighten tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove ineffective TypeError import that breaks CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
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 <cursoragent@cursor.com> * test: cover resume paths and transient Instagram rate limits Co-authored-by: Cursor <cursoragent@cursor.com> * fix: resume posts:retry only for in-flight publish failures Co-authored-by: Cursor <cursoragent@cursor.com> * fix: resume posts:retry via ErrorCategory instead of string lists Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * test: cover Instagram EXPIRED retry and published recovery paths Co-authored-by: Cursor <cursoragent@cursor.com> * fix: stop guessing Instagram media ids from recent /media Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 18:34:53 +00:00
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
});
test('it recovers posts stuck in publishing for over 1 hour', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$platform->refresh();
$post->refresh();
expect($platform->status)->toBe(PlatformStatus::Failed);
fix: Pinterest video processing timeout — longer poll + retry (#246) * fix: give Pinterest video processing more time and retry on timeout A valid ~54s video pin failed after ~90s of polling while Pinterest was still processing. Extend the poll window to ~5 minutes and treat timeout as platform unavailable so PublishToSocialPlatform reschedules instead of failing the post on the first attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: use Laravel Sleep for Pinterest media processing polls Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: inline Pinterest video processing poll constants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: map Pinterest media upload statuses to an enum Use the official MediaUploadStatus values (registered, processing, succeeded, failed) instead of comparing raw strings in the publisher. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: trim Pinterest media processing docblock * fix: cap platform-unavailable retries and recover stuck retrying posts Stop infinite reschedules after 6 attempts with a user-safe failure message, keep technical detail in error_context, recover Retrying platforms in social:recover-stuck-posts, and drop unused isTerminal(). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove unused failedCount in RecoverStuckPosts Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip final Pinterest poll sleep and localize recover timeout Co-authored-by: Cursor <cursoragent@cursor.com> * fix: raise publish job timeout headroom and ignore already-failed platforms Give social publish jobs 15 minutes so Pinterest media polling fits under the worker limit, bump Horizon/redis retry_after above that timeout, and skip handle/failed when the platform is already Failed so delayed jobs cannot revive posts recovered by social:recover-stuck-posts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: restore social-publishing and ai-assistant horizon supervisors Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON Treat media-status 401 as TokenExpired, make PublishToSocialPlatform unique per platform+attempt so retries still queue, and persist recover error_context via Eloquent casts instead of manual json_encode. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: mass-update stuck post platforms without per-row each Eloquent query updates already bind JSON arrays correctly here, so one UPDATE is enough — no manual json_encode and no N model writes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify Pinterest media processing poll loop Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify publish job retry and terminal status checks Co-authored-by: Cursor <cursoragent@cursor.com> * fix: do not finalize posts while platforms are still retrying Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover Pinterest timeout, unique jobs, and recover edge cases Co-authored-by: Cursor <cursoragent@cursor.com> * fix: retry Pinterest media poll on connection errors and tighten tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove ineffective TypeError import that breaks CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
expect($platform->error_message)->toBe(__('posts.errors.publishing_timed_out'));
expect($platform->error_context)->toMatchArray([
'category' => 'timeout',
]);
expect($post->status)->toBe(PostStatus::Failed);
});
test('it does not touch posts publishing for less than 1 hour', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subMinutes(30),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subMinutes(30),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$platform->refresh();
expect($platform->status)->toBe(PlatformStatus::Publishing);
});
test('it marks post as partially published when some platforms succeeded', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
// One succeeded
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Published,
'enabled' => true,
]);
// One stuck
$stuckPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
])->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$stuckPlatform->refresh();
$post->refresh();
expect($stuckPlatform->status)->toBe(PlatformStatus::Failed);
expect($post->status)->toBe(PostStatus::PartiallyPublished);
});
fix: Pinterest video processing timeout — longer poll + retry (#246) * fix: give Pinterest video processing more time and retry on timeout A valid ~54s video pin failed after ~90s of polling while Pinterest was still processing. Extend the poll window to ~5 minutes and treat timeout as platform unavailable so PublishToSocialPlatform reschedules instead of failing the post on the first attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: use Laravel Sleep for Pinterest media processing polls Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: inline Pinterest video processing poll constants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: map Pinterest media upload statuses to an enum Use the official MediaUploadStatus values (registered, processing, succeeded, failed) instead of comparing raw strings in the publisher. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: trim Pinterest media processing docblock * fix: cap platform-unavailable retries and recover stuck retrying posts Stop infinite reschedules after 6 attempts with a user-safe failure message, keep technical detail in error_context, recover Retrying platforms in social:recover-stuck-posts, and drop unused isTerminal(). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove unused failedCount in RecoverStuckPosts Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip final Pinterest poll sleep and localize recover timeout Co-authored-by: Cursor <cursoragent@cursor.com> * fix: raise publish job timeout headroom and ignore already-failed platforms Give social publish jobs 15 minutes so Pinterest media polling fits under the worker limit, bump Horizon/redis retry_after above that timeout, and skip handle/failed when the platform is already Failed so delayed jobs cannot revive posts recovered by social:recover-stuck-posts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: restore social-publishing and ai-assistant horizon supervisors Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON Treat media-status 401 as TokenExpired, make PublishToSocialPlatform unique per platform+attempt so retries still queue, and persist recover error_context via Eloquent casts instead of manual json_encode. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: mass-update stuck post platforms without per-row each Eloquent query updates already bind JSON arrays correctly here, so one UPDATE is enough — no manual json_encode and no N model writes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify Pinterest media processing poll loop Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify publish job retry and terminal status checks Co-authored-by: Cursor <cursoragent@cursor.com> * fix: do not finalize posts while platforms are still retrying Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover Pinterest timeout, unique jobs, and recover edge cases Co-authored-by: Cursor <cursoragent@cursor.com> * fix: retry Pinterest media poll on connection errors and tighten tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove ineffective TypeError import that breaks CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
test('it recovers platforms stuck in retrying for over 1 hour', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_message' => __('posts.errors.platform_unavailable'),
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$platform->refresh();
$post->refresh();
expect($platform->status)->toBe(PlatformStatus::Failed)
->and($platform->error_message)->toBe(__('posts.errors.publishing_timed_out'))
->and($post->status)->toBe(PostStatus::Failed);
});
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 <cursoragent@cursor.com> * test: cover resume paths and transient Instagram rate limits Co-authored-by: Cursor <cursoragent@cursor.com> * fix: resume posts:retry only for in-flight publish failures Co-authored-by: Cursor <cursoragent@cursor.com> * fix: resume posts:retry via ErrorCategory instead of string lists Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * test: cover Instagram EXPIRED retry and published recovery paths Co-authored-by: Cursor <cursoragent@cursor.com> * fix: stop guessing Instagram media ids from recent /media Co-authored-by: Cursor <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> * 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 <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 18:34:53 +00:00
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',
]);
});
fix: Pinterest video processing timeout — longer poll + retry (#246) * fix: give Pinterest video processing more time and retry on timeout A valid ~54s video pin failed after ~90s of polling while Pinterest was still processing. Extend the poll window to ~5 minutes and treat timeout as platform unavailable so PublishToSocialPlatform reschedules instead of failing the post on the first attempt. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: use Laravel Sleep for Pinterest media processing polls Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: inline Pinterest video processing poll constants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: map Pinterest media upload statuses to an enum Use the official MediaUploadStatus values (registered, processing, succeeded, failed) instead of comparing raw strings in the publisher. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: trim Pinterest media processing docblock * fix: cap platform-unavailable retries and recover stuck retrying posts Stop infinite reschedules after 6 attempts with a user-safe failure message, keep technical detail in error_context, recover Retrying platforms in social:recover-stuck-posts, and drop unused isTerminal(). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: remove unused failedCount in RecoverStuckPosts Co-authored-by: Cursor <cursoragent@cursor.com> * fix: skip final Pinterest poll sleep and localize recover timeout Co-authored-by: Cursor <cursoragent@cursor.com> * fix: raise publish job timeout headroom and ignore already-failed platforms Give social publish jobs 15 minutes so Pinterest media polling fits under the worker limit, bump Horizon/redis retry_after above that timeout, and skip handle/failed when the platform is already Failed so delayed jobs cannot revive posts recovered by social:recover-stuck-posts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: restore social-publishing and ai-assistant horizon supervisors Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON Treat media-status 401 as TokenExpired, make PublishToSocialPlatform unique per platform+attempt so retries still queue, and persist recover error_context via Eloquent casts instead of manual json_encode. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: mass-update stuck post platforms without per-row each Eloquent query updates already bind JSON arrays correctly here, so one UPDATE is enough — no manual json_encode and no N model writes. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify Pinterest media processing poll loop Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: simplify publish job retry and terminal status checks Co-authored-by: Cursor <cursoragent@cursor.com> * fix: do not finalize posts while platforms are still retrying Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover Pinterest timeout, unique jobs, and recover edge cases Co-authored-by: Cursor <cursoragent@cursor.com> * fix: retry Pinterest media poll on connection errors and tighten tests Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove ineffective TypeError import that breaks CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:49:37 +00:00
test('it does not finalize a post while a platform is still actively retrying', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_message' => __('posts.errors.platform_unavailable'),
'updated_at' => now()->subMinutes(5),
]);
$this->artisan('social:recover-stuck-posts')
->expectsOutput('Recovered 0 stuck posts.')
->assertSuccessful();
$platform->refresh();
$post->refresh();
expect($platform->status)->toBe(PlatformStatus::Retrying)
->and($post->status)->toBe(PostStatus::Publishing);
});
test('it does not finalize a post while a platform is still actively pending or publishing', function (PlatformStatus $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => $status,
'enabled' => true,
'updated_at' => now()->subMinutes(5),
]);
$this->artisan('social:recover-stuck-posts')
->expectsOutput('Recovered 0 stuck posts.')
->assertSuccessful();
$platform->refresh();
$post->refresh();
expect($platform->status)->toBe($status)
->and($post->status)->toBe(PostStatus::Publishing);
})->with([
PlatformStatus::Pending,
PlatformStatus::Publishing,
]);
test('it fails stale platforms but keeps the post publishing when another platform is still retrying', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$stalePlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subHours(2),
]);
$activeRetry = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Pinterest,
])->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_message' => __('posts.errors.platform_unavailable'),
'updated_at' => now()->subMinutes(5),
]);
$this->artisan('social:recover-stuck-posts')
->expectsOutput('Recovered 0 stuck posts.')
->assertSuccessful();
$stalePlatform->refresh();
$activeRetry->refresh();
$post->refresh();
expect($stalePlatform->status)->toBe(PlatformStatus::Failed)
->and($stalePlatform->error_message)->toBe(__('posts.errors.publishing_timed_out'))
->and($activeRetry->status)->toBe(PlatformStatus::Retrying)
->and($post->status)->toBe(PostStatus::Publishing);
});
test('delayed publish job no-ops after recover fails a stuck retrying platform', function () {
Event::fake();
Mail::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_message' => __('posts.errors.platform_unavailable'),
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$platform->refresh();
expect($platform->status)->toBe(PlatformStatus::Failed)
->and($platform->error_message)->toBe(__('posts.errors.publishing_timed_out'));
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldNotReceive('publish');
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($platform))->handle();
$platform->refresh();
$post->refresh();
expect($platform->status)->toBe(PlatformStatus::Failed)
->and($platform->error_message)->toBe(__('posts.errors.publishing_timed_out'))
->and($post->status)->toBe(PostStatus::Failed);
});