2026-04-01 14:25:25 +00:00
|
|
|
<?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;
|
2026-04-01 14:25:25 +00:00
|
|
|
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;
|
2026-08-16 18:34:53 +00:00
|
|
|
use Illuminate\Support\Facades\Storage;
|
2026-04-01 14:25:25 +00:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
]);
|
2026-04-01 14:25:25 +00:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
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);
|
|
|
|
|
});
|