trypost/tests/Feature/Commands/RetryFailedPostTest.php

813 lines
32 KiB
PHP
Raw Permalink Normal View History

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
<?php
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Exceptions\Social\ErrorCategory;
use App\Jobs\PublishToSocialPlatform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$this->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');
});