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>
This commit is contained in:
Paulo Castellano 2026-08-16 15:34:53 -03:00 committed by GitHub
parent 2f6c006bfb
commit 4546425532
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3749 additions and 328 deletions

View file

@ -6,7 +6,10 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Exceptions\Social\ErrorCategory;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
use Illuminate\Console\Command;
class RecoverStuckPosts extends Command
@ -15,6 +18,12 @@ class RecoverStuckPosts extends Command
protected $description = 'Recover posts stuck in publishing status for more than 1 hour';
public function __construct(
private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner,
) {
parent::__construct();
}
public function handle(): void
{
$count = 0;
@ -23,18 +32,28 @@ public function handle(): void
->where('status', PostStatus::Publishing)
->where('updated_at', '<=', now()->subHour())
->each(function (Post $post) use (&$count) {
$post->postPlatforms()
$stalePlatforms = $post->postPlatforms()
->enabled()
->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying])
->where('updated_at', '<=', now()->subHour())
->update([
->get();
$stalePlatforms->each(function (PostPlatform $postPlatform): void {
$this->tiktokPhotoDerivativeCleaner->cleanupUnlessPublishInFlight(
$postPlatform->error_context,
$postPlatform->id,
);
$postPlatform->update([
'status' => PlatformStatus::Failed,
'error_message' => __('posts.errors.publishing_timed_out'),
'error_context' => [
'category' => 'timeout',
...($postPlatform->error_context ?? []),
'category' => ErrorCategory::Timeout->value,
'failed_at' => now()->toIso8601String(),
],
]);
});
// Delayed platform-unavailable retries keep the platform Retrying with a
// fresh updated_at — do not finalize the post while that work is still live.

View file

@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Exceptions\Social\ErrorCategory;
use App\Jobs\PublishToSocialPlatform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Support\Social\PublishCheckpoint;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class RetryFailedPost extends Command
{
protected $signature = 'posts:retry
{post : ID of the post whose failed platforms should be retried}';
protected $description = 'Retry failed platforms, resuming in-flight remote publishes when a checkpoint exists';
public function __construct(
private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner,
) {
parent::__construct();
}
public function handle(): int
{
$post = Post::query()->find((string) $this->argument('post'));
if (! $post) {
$this->error('Post not found.');
return self::FAILURE;
}
if (! $this->isRetryable($post)) {
$this->error('Only failed or partially published posts can be retried.');
return self::FAILURE;
}
$failedPlatforms = $this->failedPlatforms($post);
if ($failedPlatforms->isEmpty()) {
$this->warn('No failed enabled platforms matched this post.');
return self::FAILURE;
}
$this->table(
['Post platform ID', 'Platform', 'Account', 'Last error', 'Mode'],
$failedPlatforms->map(fn (PostPlatform $postPlatform): array => [
$postPlatform->id,
$postPlatform->platform->value,
$postPlatform->display_username ?? '—',
$postPlatform->error_message ?? '—',
$this->resumableContext($postPlatform->error_context) === null ? 'New' : 'Resume',
])->all(),
);
if (! $this->confirm('Queue publish attempts for these failed platforms?')) {
$this->info('Retry cancelled.');
return self::SUCCESS;
}
$retryEntries = $this->prepareRetryEntries($post);
if ($retryEntries === []) {
$this->warn('The post changed while the command was running; nothing was retried.');
return self::FAILURE;
}
foreach ($retryEntries as $entry) {
if ($entry['platform'] === SocialPlatform::TikTok && PublishCheckpoint::tiktokPublishId($entry['error_context']) === null) {
$this->tiktokPhotoDerivativeCleaner->cleanup($entry['original_error_context'], $entry['id']);
}
$postPlatform = PostPlatform::query()->findOrFail($entry['id']);
PublishToSocialPlatform::dispatch($postPlatform);
}
Log::info('Failed post platforms queued for manual retry', [
'post_id' => $post->id,
'post_platform_ids' => array_column($retryEntries, 'id'),
]);
$this->info(count($retryEntries).' publish attempt(s) queued.');
return self::SUCCESS;
}
private function isRetryable(Post $post): bool
{
return in_array($post->status, [PostStatus::Failed, PostStatus::PartiallyPublished], true);
}
/**
* @return Collection<int, PostPlatform>
*/
private function failedPlatforms(Post $post, bool $lockForUpdate = false): Collection
{
return PostPlatform::query()
->with('socialAccount')
->where('post_id', $post->id)
->enabled()
->where('status', PlatformStatus::Failed)
->when($lockForUpdate, fn (Builder $query) => $query->lockForUpdate())
->get();
}
/**
* @return list<array{
* id: string,
* platform: SocialPlatform,
* error_context: array<string, mixed>|null,
* original_error_context: array<string, mixed>|null
* }>
*/
private function prepareRetryEntries(Post $post): array
{
return DB::transaction(function () use ($post): array {
$lockedPost = Post::query()->lockForUpdate()->find($post->id);
if (! $lockedPost || ! $this->isRetryable($lockedPost)) {
return [];
}
$platforms = $this->failedPlatforms($lockedPost, lockForUpdate: true);
if ($platforms->isEmpty()) {
return [];
}
$entries = [];
foreach ($platforms as $postPlatform) {
$nextContext = $this->resumableContext($postPlatform->error_context);
$entries[] = [
'id' => $postPlatform->id,
'platform' => $postPlatform->platform,
'error_context' => $nextContext,
'original_error_context' => $postPlatform->error_context,
];
$postPlatform->update([
'status' => PlatformStatus::Pending,
'platform_post_id' => null,
'platform_url' => null,
'error_message' => null,
'error_context' => $nextContext,
'published_at' => null,
]);
}
$lockedPost->update(['status' => PostStatus::Publishing]);
return $entries;
});
}
/**
* Keep in-flight checkpoints only. Confirmed remote failures must start over.
*
* @param array<string, mixed>|null $context
* @return array<string, mixed>|null
*/
private function resumableContext(?array $context): ?array
{
if (ErrorCategory::tryFromContext($context)?->isResumable() !== true) {
return null;
}
$kept = [];
$publishId = PublishCheckpoint::tiktokPublishId($context);
$workflow = PublishCheckpoint::instagramWorkflow($context);
if ($publishId !== null) {
$kept[PublishCheckpoint::TIKTOK_PUBLISH_ID] = $publishId;
$paths = PublishCheckpoint::tiktokDerivativePaths($context);
if ($paths !== []) {
$kept[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $paths;
}
}
if ($workflow !== null) {
$kept[PublishCheckpoint::INSTAGRAM_WORKFLOW] = $workflow;
}
return $kept === [] ? null : $kept;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums\Instagram;
/**
* IG Container `status_code` values from GET /{IG_CONTAINER_ID}.
*
* @see https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-container/
*/
enum ContainerStatus: string
{
case Expired = 'EXPIRED';
case Error = 'ERROR';
case Finished = 'FINISHED';
case InProgress = 'IN_PROGRESS';
case Published = 'PUBLISHED';
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums\TikTok;
/**
* TikTok `data.status` values from POST /v2/post/publish/status/fetch/.
*
* @see https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status
*/
enum PublishStatus: string
{
case ProcessingUpload = 'PROCESSING_UPLOAD';
case ProcessingDownload = 'PROCESSING_DOWNLOAD';
case SendToUserInbox = 'SEND_TO_USER_INBOX';
case PublishComplete = 'PUBLISH_COMPLETE';
case Failed = 'FAILED';
}

View file

@ -7,16 +7,22 @@
use Exception;
/**
* Raised when a social platform's API is unreachable or returning a server
* error during a token verify/refresh. Distinct from TokenExpiredException
* because the account's token is not provably invalid the platform is
* just down. Callers should retry later instead of disconnecting the user.
* Raised when a social platform operation is temporarily unavailable or still
* processing. Distinct from TokenExpiredException because the account's token
* is not provably invalid. Callers should retry later instead of disconnecting
* the user or reporting a definitive publish failure.
*/
class PlatformUnavailableException extends Exception
{
/**
* @param array<string, mixed> $context
*/
public function __construct(
string $message = 'Platform API is unavailable',
public ?int $httpStatus = null,
public array $context = [],
public ?int $retryDelaySeconds = null,
public ?int $maxRetries = null,
) {
parent::__construct($message);
}

View file

@ -12,4 +12,26 @@ enum ErrorCategory: string
case ContentPolicy = 'content_policy';
case ServerError = 'server_error';
case Unknown = 'unknown';
case PlatformUnavailable = 'platform_unavailable';
case Timeout = 'timeout';
case TokenExpired = 'token_expired';
case JobFailed = 'job_failed';
public function isResumable(): bool
{
return match ($this) {
self::PlatformUnavailable, self::Timeout, self::TokenExpired, self::JobFailed => true,
default => false,
};
}
/**
* @param array<string, mixed>|null $context
*/
public static function tryFromContext(?array $context): ?self
{
$category = data_get($context, 'category');
return is_string($category) ? self::tryFrom($category) : null;
}
}

View file

@ -11,6 +11,7 @@
use App\Enums\SocialAccount\Status;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\SocialPublishException;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostPublished;
@ -31,25 +32,32 @@
use App\Services\Social\TikTokPublisher;
use App\Services\Social\XPublisher;
use App\Services\Social\YouTubePublisher;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;
use Throwable;
class PublishToSocialPlatform implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 1;
public int $tries = 20;
public int $maxExceptions = 1;
/** Download/upload + Pinterest poll headroom; keep Horizon/Redis timeouts above this. */
public int $timeout = 900;
public int $uniqueFor = 960;
/** Max platform-unavailable reschedules (~1 hour at 10 min each). */
/** Default platform-unavailable retry budget (~1 hour at 10 minutes each). */
public const MAX_PLATFORM_UNAVAILABLE_RETRIES = 6;
private const int DEFAULT_RETRY_DELAY_SECONDS = 600;
public function __construct(
public PostPlatform $postPlatform,
public int $uniqueAttempt = 0,
@ -62,6 +70,18 @@ public function uniqueId(): string
return "{$this->postPlatform->id}:{$this->uniqueAttempt}";
}
/**
* @return array<int, object>
*/
public function middleware(): array
{
return [
(new WithoutOverlapping("social-publish:{$this->postPlatform->id}"))
->releaseAfter(60)
->expireAfter($this->timeout + 60),
];
}
public function handle(): void
{
$this->postPlatform->refresh();
@ -71,48 +91,28 @@ public function handle(): void
}
if (! $this->postPlatform->socialAccount->is_active) {
$this->postPlatform->markAsFailed(__('posts.errors.account_inactive'));
$this->updatePostStatus();
$this->broadcastStatus();
$this->failAndFinalize(__('posts.errors.account_inactive'));
return;
}
if ($this->postPlatform->socialAccount->status === Status::Disconnected) {
$this->postPlatform->markAsFailed(__('posts.errors.account_disconnected'));
$this->updatePostStatus();
$this->broadcastStatus();
$this->failAndFinalize(__('posts.errors.account_disconnected'));
return;
}
if ($this->postPlatform->socialAccount->status === Status::TokenExpired) {
$this->postPlatform->markAsFailed(__('posts.errors.account_token_expired'), [
'category' => 'token_expired',
$this->failAndFinalize(__('posts.errors.account_token_expired'), [
'category' => ErrorCategory::TokenExpired->value,
'failed_at' => now()->toIso8601String(),
]);
$this->updatePostStatus();
$this->broadcastStatus();
return;
}
$requiredScopes = $this->postPlatform->platform->requiredPublishScopes();
$accountScopes = $this->postPlatform->socialAccount->scopes ?? [];
if (! empty($requiredScopes)) {
$missingScopes = array_diff($requiredScopes, $accountScopes);
if (! empty($missingScopes)) {
$this->postPlatform->markAsFailed(
'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.',
['category' => 'permission', 'missing_scopes' => $missingScopes, 'failed_at' => now()->toIso8601String()]
);
$this->updatePostStatus();
$this->broadcastStatus();
return;
}
if ($this->failForMissingScopes()) {
return;
}
$this->postPlatform->markAsPublishing();
@ -138,7 +138,7 @@ public function handle(): void
} catch (PlatformUnavailableException $refreshError) {
$this->rescheduleForRetry($refreshError);
break;
} catch (\Throwable $refreshError) {
} catch (Throwable $refreshError) {
Log::error('Token refresh failed during publish retry', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
@ -155,8 +155,8 @@ public function handle(): void
'platform_error_code' => $e->platformErrorCode,
]);
$this->postPlatform->markAsFailed($e->getMessage(), [
'category' => 'token_expired',
$this->markPlatformAsFailed($e->getMessage(), [
'category' => ErrorCategory::TokenExpired->value,
'platform_error_code' => $e->platformErrorCode,
'failed_at' => now()->toIso8601String(),
]);
@ -164,7 +164,7 @@ public function handle(): void
break;
} catch (SocialPublishException $e) {
Log::error('Social publish failed: '.$e->userMessage);
$this->postPlatform->markAsFailed($e->userMessage, [
$this->markPlatformAsFailed($e->userMessage, [
'category' => $e->category->value,
'platform_error_code' => $e->platformErrorCode,
'failed_at' => now()->toIso8601String(),
@ -173,14 +173,14 @@ public function handle(): void
'raw_response' => $e->context()['raw_response'],
]);
break;
} catch (\Throwable $e) {
} catch (Throwable $e) {
Log::error('Failed to publish to social platform', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
]);
$this->postPlatform->markAsFailed($this->safeFailureMessage($e), [
'category' => 'unknown',
$this->markPlatformAsFailed($this->safeFailureMessage($e), [
'category' => ErrorCategory::Unknown->value,
'failed_at' => now()->toIso8601String(),
'content_length' => mb_strlen($this->postPlatform->post->content ?? ''),
'media_count' => count($this->postPlatform->post->media ?? []),
@ -204,24 +204,57 @@ private function refreshAccountToken(): void
app(ConnectionVerifier::class)->verify($account);
}
private function failForMissingScopes(): bool
{
$missingScopes = array_values(array_diff(
$this->postPlatform->platform->requiredPublishScopes(),
$this->postPlatform->socialAccount->scopes ?? [],
));
if ($missingScopes === []) {
return false;
}
$this->failAndFinalize(
'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.',
[
'category' => ErrorCategory::Permission->value,
'missing_scopes' => $missingScopes,
'failed_at' => now()->toIso8601String(),
],
);
return true;
}
private function rescheduleForRetry(PlatformUnavailableException $e): void
{
$retryCount = (int) data_get($this->postPlatform->error_context, 'retry_count', 0) + 1;
$maxRetries = (int) ($e->maxRetries
?? data_get($this->postPlatform->error_context, 'max_retries')
?? self::MAX_PLATFORM_UNAVAILABLE_RETRIES);
$retryDelaySeconds = (int) ($e->retryDelaySeconds
?? data_get($this->postPlatform->error_context, 'retry_delay_seconds')
?? self::DEFAULT_RETRY_DELAY_SECONDS);
$context = [
'category' => 'platform_unavailable',
...($this->postPlatform->error_context ?? []),
...$e->context,
'category' => ErrorCategory::PlatformUnavailable->value,
'http_status' => $e->httpStatus,
'retry_count' => $retryCount,
'max_retries' => $maxRetries,
'retry_delay_seconds' => $retryDelaySeconds,
'detail' => $e->getMessage(),
];
if ($retryCount > self::MAX_PLATFORM_UNAVAILABLE_RETRIES) {
if ($retryCount > $maxRetries) {
Log::warning('Publish retries exhausted: platform unavailable', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
...$context,
]);
$this->postPlatform->markAsFailed(
$this->markPlatformAsFailed(
__('posts.errors.platform_unavailable_exhausted'),
[...$context, 'failed_at' => now()->toIso8601String()],
);
@ -229,7 +262,7 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void
return;
}
$nextAttemptAt = now()->addMinutes(10);
$nextAttemptAt = now()->addSeconds($retryDelaySeconds);
Log::warning('Publish rescheduled: platform unavailable', [
'post_platform_id' => $this->postPlatform->id,
@ -251,6 +284,35 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void
self::dispatch($this->postPlatform, $retryCount)->delay($nextAttemptAt);
}
/**
* @param array<string, mixed>|null $context
*/
private function markPlatformAsFailed(string $message, ?array $context = null): void
{
$previousContext = $this->postPlatform->error_context ?? [];
if ($this->postPlatform->platform === SocialPlatform::TikTok) {
app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight(
$previousContext,
$this->postPlatform->id,
);
}
$failureContext = [...$previousContext, ...($context ?? [])];
$this->postPlatform->markAsFailed($message, $failureContext === [] ? null : $failureContext);
}
/**
* @param array<string, mixed>|null $context
*/
private function failAndFinalize(string $message, ?array $context = null): void
{
$this->markPlatformAsFailed($message, $context);
$this->updatePostStatus();
$this->broadcastStatus();
}
private function isTerminal(): bool
{
return in_array($this->postPlatform->status, [
@ -268,7 +330,7 @@ private function broadcastStatus(): void
* A user-safe failure message: only our own publish exceptions are shown
* verbatim; anything else is genericized so internals never reach the email.
*/
private function safeFailureMessage(\Throwable $e): string
private function safeFailureMessage(Throwable $e): string
{
return $e instanceof SocialPublishException
? $e->userMessage
@ -311,45 +373,21 @@ private function updatePostStatus(): void
if ($publishedCount === $total) {
$post->markAsPublished();
$this->notifySuccess($post);
} elseif ($publishedCount > 0) {
$post->markAsPartiallyPublished();
$this->notifyFailure($post);
} else {
$post->markAsFailed();
$this->notifyFailure($post);
}
}
$this->notify($post, PostPlatformStatus::Published);
private function notifySuccess(Post $post): void
{
$owner = $post->workspace->owner;
if (! $owner) {
return;
}
$publishedPlatforms = $post->postPlatforms()
->with('socialAccount')
->enabled()
->get()
->filter(fn ($pp) => $pp->status === PostPlatformStatus::Published)
->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')')
->implode(', ');
if ($publishedCount > 0) {
$post->markAsPartiallyPublished();
} else {
$post->markAsFailed();
}
SendNotification::dispatch(
user: $owner,
workspaceId: $post->workspace_id,
type: Type::PostPublished,
channel: Channel::Both,
title: 'Post published successfully',
body: $publishedPlatforms,
data: ['post_id' => $post->id],
mailable: new PostPublished($post),
);
$this->notify($post, PostPlatformStatus::Failed);
}
public function failed(?\Throwable $exception): void
public function failed(?Throwable $exception): void
{
Log::error('PublishToSocialPlatform job failed permanently', [
'post_platform_id' => $this->postPlatform->id,
@ -363,10 +401,10 @@ public function failed(?\Throwable $exception): void
return;
}
$this->postPlatform->markAsFailed(
$this->markPlatformAsFailed(
$exception ? $this->safeFailureMessage($exception) : 'Unknown error',
[
'category' => 'job_failed',
'category' => ErrorCategory::JobFailed->value,
'failed_at' => now()->toIso8601String(),
]
);
@ -374,7 +412,7 @@ public function failed(?\Throwable $exception): void
$this->broadcastStatus();
}
private function notifyFailure(Post $post): void
private function notify(Post $post, PostPlatformStatus $status): void
{
$owner = $post->workspace->owner;
@ -382,23 +420,24 @@ private function notifyFailure(Post $post): void
return;
}
$failedPlatforms = $post->postPlatforms()
$successful = $status === PostPlatformStatus::Published;
$platforms = $post->postPlatforms()
->with('socialAccount')
->enabled()
->where('status', $status)
->get()
->filter(fn ($pp) => $pp->status === PostPlatformStatus::Failed)
->map(fn ($pp) => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', '').')')
->implode(', ');
SendNotification::dispatch(
user: $owner,
workspaceId: $post->workspace_id,
type: Type::PostFailed,
type: $successful ? Type::PostPublished : Type::PostFailed,
channel: Channel::Both,
title: 'Post failed to publish',
body: "Failed on: {$failedPlatforms}",
title: $successful ? 'Post published successfully' : 'Post failed to publish',
body: $successful ? $platforms : "Failed on: {$platforms}",
data: ['post_id' => $post->id],
mailable: new PostPublishFailed($post),
mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post),
);
}
}

View file

@ -20,6 +20,7 @@
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use RuntimeException;
use Throwable;
@ -36,7 +37,7 @@ class BlueskyPublisher
/** Re-upload a transiently-failing transcode this many times before giving up. */
private const VIDEO_UPLOAD_ATTEMPTS = 3;
/** Poll getJobStatus up to this many times (× the configured interval) before timing out. */
/** Poll getJobStatus up to this many times before timing out. */
private const VIDEO_POLL_MAX_ATTEMPTS = 150;
/** Wall-clock budget (seconds) for the whole upload+poll+retry flow, kept under the 600s job timeout so a stuck transcode degrades to text instead of being killed mid-flight. */
@ -477,8 +478,6 @@ private function attemptVideoUpload(SocialAccount $account, string $uploadToken,
private function pollVideoJob(string $statusToken, string $jobId, CarbonInterface $deadline): ?array
{
$statusUrl = (string) config('trypost.platforms.bluesky.video_service').'/xrpc/'.BlueskyLexicon::VIDEO_GET_JOB_STATUS;
$intervalSeconds = (int) config('trypost.platforms.bluesky.video_poll_seconds');
// Processing usually finishes within seconds. State is checked before
// sleeping so an already-complete job returns at once. The attempt cap
// and the wall-clock deadline both bound the loop.
@ -514,7 +513,7 @@ private function pollVideoJob(string $statusToken, string $jobId, CarbonInterfac
return null;
}
sleep($intervalSeconds);
Sleep::for($this->videoPollDelaySeconds($attempt))->seconds();
}
Log::error('Bluesky video processing timed out', ['jobId' => $jobId]);
@ -522,6 +521,15 @@ private function pollVideoJob(string $statusToken, string $jobId, CarbonInterfac
return null;
}
private function videoPollDelaySeconds(int $attempt): int
{
$initialSeconds = max(0, (int) config('trypost.platforms.bluesky.video_poll_seconds'));
$maxSeconds = max($initialSeconds, (int) config('trypost.platforms.bluesky.video_poll_max_seconds'));
$multiplier = 2 ** intdiv($attempt, 3);
return min($initialSeconds * $multiplier, $maxSeconds);
}
/**
* uploadVideo returns the job status at the top level, while getJobStatus
* wraps it under a `jobStatus` key. Fall back to the top level only when the

View file

@ -4,14 +4,20 @@
namespace App\Services\Social;
use App\Enums\Instagram\ContainerStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\Social\SocialPublishException;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\CropsImageForAspectRatio;
use App\Services\Social\Concerns\HasSocialHttpClient;
use App\Services\Social\Meta\GraphError;
use App\Support\Social\PublishCheckpoint;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
@ -22,8 +28,19 @@ class InstagramPublisher
private string $baseUrl;
private PostPlatform $postPlatform;
private const int STATUS_RETRY_DELAY_SECONDS = 10;
private const int STATUS_MAX_RETRIES = 90;
private const string WORKFLOW_CAROUSEL_CHILDREN = 'carousel_children';
private const string WORKFLOW_FINAL_CONTAINER = 'final_container';
public function publish(PostPlatform $postPlatform): array
{
$this->postPlatform = $postPlatform;
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
@ -38,6 +55,12 @@ public function publish(PostPlatform $postPlatform): array
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$pendingWorkflow = PublishCheckpoint::instagramWorkflow($postPlatform->error_context);
if ($pendingWorkflow !== null) {
return $this->resumeWorkflow($instagramId, $accessToken, $content, $pendingWorkflow);
}
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
@ -94,65 +117,21 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s
$params['alt_text'] = $alt;
}
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
$containerId = $this->createContainer($instagramId, $params, 'container');
if ($containerResponse->failed()) {
Log::error('Instagram container creation failed', [
'status' => $containerResponse->status(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new InstagramPublishException(
userMessage: 'Instagram container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 2: Wait for container to be ready
$this->waitForMediaProcessing($containerId, $accessToken);
// Step 3: Publish container
return $this->publishContainer($instagramId, $accessToken, $containerId);
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array
{
// Step 1: Create container for video/reel
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
$containerId = $this->createContainer($instagramId, [
'video_url' => $media->url,
'caption' => $content,
'media_type' => 'REELS',
'access_token' => $accessToken,
]);
], 'reel container');
if ($containerResponse->failed()) {
Log::error('Instagram reel container creation failed', [
'status' => $containerResponse->status(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new InstagramPublishException(
userMessage: 'Instagram reel container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Wait for video processing
$this->waitForMediaProcessing($containerId, $accessToken);
// Step 2: Publish container
return $this->publishContainer($instagramId, $accessToken, $containerId);
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
private function publishStory(string $instagramId, string $accessToken, $media): array
@ -171,37 +150,16 @@ private function publishStory(string $instagramId, string $accessToken, $media):
$params['image_url'] = $this->fitImageToCanvas($media->url, data_get($dimensions, 'width'), data_get($dimensions, 'height'));
}
// Step 1: Create story container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);
$containerId = $this->createContainer($instagramId, $params, 'story container');
if ($containerResponse->failed()) {
Log::error('Instagram story container creation failed', [
'status' => $containerResponse->status(),
'body' => $this->redactResponseBody($containerResponse->body()),
]);
$this->handleApiError($containerResponse);
}
$containerId = $containerResponse->json()['id'] ?? null;
if (! $containerId) {
throw new InstagramPublishException(
userMessage: 'Instagram story container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 2: Wait for media processing
$this->waitForMediaProcessing($containerId, $accessToken);
// Step 3: Publish story container
return $this->publishContainer($instagramId, $accessToken, $containerId);
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
private function publishCarousel(string $instagramId, string $accessToken, ?string $content, $mediaCollection, ?string $aspectRatio): array
{
// Step 1: Create containers for each media item
$childContainers = [];
$processingChildContainers = [];
foreach ($mediaCollection as $media) {
$isVideo = $media->isVideo();
@ -234,20 +192,19 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
continue;
}
$childId = $containerResponse->json()['id'] ?? null;
$childId = $this->stringId(data_get($containerResponse->json(), 'id'));
if (! $childId) {
if ($childId === null) {
Log::error('Instagram carousel item creation returned no ID', ['body' => $this->redactResponseBody($containerResponse->body())]);
continue;
}
// Wait for video processing if needed
if ($isVideo) {
$this->waitForMediaProcessing($childId, $accessToken);
}
$childContainers[] = $childId;
if ($isVideo) {
$processingChildContainers[] = $childId;
}
}
if (empty($childContainers)) {
@ -257,73 +214,128 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
);
}
// Step 2: Create carousel container
$carouselResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
return $this->finishCarousel($instagramId, $accessToken, $content, $childContainers, $processingChildContainers);
}
/**
* @param list<string> $childContainers
* @param list<string> $processingChildContainers
*/
private function finishCarousel(string $instagramId, string $accessToken, ?string $content, array $childContainers, array $processingChildContainers): array
{
$workflow = [
'stage' => self::WORKFLOW_CAROUSEL_CHILDREN,
'child_container_ids' => $childContainers,
'processing_child_container_ids' => $processingChildContainers,
];
foreach ($processingChildContainers as $childId) {
$this->waitForMediaProcessing($childId, $accessToken, $workflow);
}
$carouselId = $this->createContainer($instagramId, [
'media_type' => 'CAROUSEL',
'caption' => $content,
'children' => implode(',', $childContainers),
'access_token' => $accessToken,
], 'carousel container');
return $this->finishContainer($instagramId, $accessToken, $carouselId);
}
/**
* @param array<string, mixed> $workflow
*/
private function resumeWorkflow(string $instagramId, string $accessToken, ?string $content, array $workflow): array
{
$mediaId = $this->stringId(data_get($workflow, 'media_id'));
if ($mediaId !== null) {
return $this->publishedMedia($mediaId, $accessToken);
}
$stage = data_get($workflow, 'stage');
if ($stage === self::WORKFLOW_FINAL_CONTAINER) {
$containerId = $this->stringId(data_get($workflow, 'container_id'));
if ($containerId !== null) {
return $this->finishContainer($instagramId, $accessToken, $containerId);
}
}
if ($stage === self::WORKFLOW_CAROUSEL_CHILDREN) {
$children = $this->stringList(data_get($workflow, 'child_container_ids'));
$processingChildren = $this->stringList(data_get($workflow, 'processing_child_container_ids', []));
if ($children !== null && $children !== [] && $processingChildren !== null) {
return $this->finishCarousel(
$instagramId,
$accessToken,
$content,
$children,
$processingChildren,
);
}
}
throw new InstagramPublishException(
userMessage: 'Instagram publish state is invalid and cannot be resumed.',
category: ErrorCategory::ServerError,
);
}
private function finishContainer(string $instagramId, string $accessToken, string $containerId): array
{
$status = $this->waitForMediaProcessing($containerId, $accessToken, [
'stage' => self::WORKFLOW_FINAL_CONTAINER,
'container_id' => $containerId,
]);
if ($carouselResponse->failed()) {
Log::error('Instagram carousel container creation failed', [
'body' => $this->redactResponseBody($carouselResponse->body()),
]);
$this->handleApiError($carouselResponse);
if ($status === ContainerStatus::Published) {
return $this->alreadyPublishedContainer($containerId);
}
$carouselId = $carouselResponse->json()['id'] ?? null;
if (! $carouselId) {
throw new InstagramPublishException(
userMessage: 'Instagram carousel container creation failed: No container ID returned',
category: ErrorCategory::ServerError,
);
}
// Step 3: Wait for carousel to be ready
$this->waitForMediaProcessing($carouselId, $accessToken);
// Step 4: Publish carousel
return $this->publishContainer($instagramId, $accessToken, $carouselId);
return $this->publishContainer($instagramId, $accessToken, $containerId);
}
private function publishContainer(string $instagramId, string $accessToken, string $containerId): array
{
$publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [
'creation_id' => $containerId,
'access_token' => $accessToken,
]);
$workflow = [
'stage' => self::WORKFLOW_FINAL_CONTAINER,
'container_id' => $containerId,
];
$publishResponse = $this->sendGraphRequest(
fn (): Response => $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media_publish", [
'creation_id' => $containerId,
'access_token' => $accessToken,
]),
$containerId,
$workflow,
);
if ($publishResponse->failed()) {
Log::error('Instagram publish failed', [
'status' => $publishResponse->status(),
'body' => $this->redactResponseBody($publishResponse->body()),
]);
if (GraphError::isTransientFailure($publishResponse)) {
throw $this->pendingContainerException($containerId, $workflow, $publishResponse->status());
}
$this->handleApiError($publishResponse);
}
$mediaId = $publishResponse->json()['id'] ?? null;
$mediaId = $this->requireGraphId(
$publishResponse->json()['id'] ?? null,
'Instagram publish failed: no media ID returned',
);
if (! $mediaId) {
throw new InstagramPublishException(
userMessage: 'Instagram publish failed: no media ID returned',
category: ErrorCategory::ServerError,
);
}
$this->rememberPublishedMedia($containerId, $mediaId);
// Get permalink
$permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [
'fields' => 'permalink',
'access_token' => $accessToken,
]);
$permalink = $permalinkResponse->json()['permalink'] ?? null;
return [
'id' => $mediaId,
'url' => $permalink,
];
return $this->publishedMedia($mediaId, $accessToken);
}
protected function cropFailureException(string $message): SocialPublishException
@ -334,37 +346,202 @@ protected function cropFailureException(string $message): SocialPublishException
);
}
private function waitForMediaProcessing(string $containerId, string $accessToken, int $maxAttempts = 30): void
/**
* @param array<string, mixed> $workflow
*/
private function waitForMediaProcessing(string $containerId, string $accessToken, array $workflow): ContainerStatus
{
for ($i = 0; $i < $maxAttempts; $i++) {
$statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [
$statusResponse = $this->sendGraphRequest(
fn (): Response => $this->socialHttp()->get("{$this->baseUrl}/{$containerId}", [
'fields' => 'status_code',
'access_token' => $accessToken,
]),
$containerId,
$workflow,
);
if ($statusResponse->failed()) {
if (! GraphError::isTransientFailure($statusResponse)) {
$this->handleApiError($statusResponse);
}
throw $this->pendingContainerException($containerId, $workflow, $statusResponse->status());
}
$status = ContainerStatus::tryFrom((string) ($statusResponse->json()['status_code'] ?? ''));
return match ($status) {
ContainerStatus::Finished, ContainerStatus::Published => $status,
ContainerStatus::Error => throw new InstagramPublishException(
userMessage: 'Instagram media processing failed',
category: ErrorCategory::ServerError,
),
ContainerStatus::Expired => throw new InstagramPublishException(
userMessage: 'Media container expired. Please try again in a few minutes.',
category: ErrorCategory::ServerError,
),
default => throw $this->pendingContainerException($containerId, $workflow),
};
}
/**
* Persist the media id before the permalink fetch so a crash after
* media_publish can resume with the real id instead of guessing from /media.
*/
private function rememberPublishedMedia(string $containerId, string $mediaId): void
{
$this->postPlatform->update([
'error_context' => [
...($this->postPlatform->error_context ?? []),
PublishCheckpoint::INSTAGRAM_WORKFLOW => [
'stage' => self::WORKFLOW_FINAL_CONTAINER,
'container_id' => $containerId,
'media_id' => $mediaId,
],
],
]);
}
/**
* @return array{id: string, url: string|null}
*/
private function publishedMedia(string $mediaId, string $accessToken): array
{
try {
$permalinkResponse = $this->socialHttp()->get("{$this->baseUrl}/{$mediaId}", [
'fields' => 'permalink',
'access_token' => $accessToken,
]);
} catch (ConnectionException) {
return [
'id' => $mediaId,
'url' => null,
];
}
$permalink = data_get($permalinkResponse->json(), 'permalink');
return [
'id' => $mediaId,
'url' => $this->stringId($permalink),
];
}
/**
* The container node has no published media id. Listing /media or /stories
* and taking data.0 can bind a different post from the same account, so
* keep the container id until media_publish has checkpointed a media_id.
*
* @return array{id: string, url: string|null}
*/
private function alreadyPublishedContainer(string $containerId): array
{
return [
'id' => $containerId,
'url' => null,
];
}
/**
* @param Closure(): Response $request
* @param array<string, mixed>|null $workflow
*/
private function sendGraphRequest(Closure $request, ?string $containerId = null, ?array $workflow = null): Response
{
try {
return $request();
} catch (ConnectionException $e) {
if ($containerId !== null && $workflow !== null) {
throw $this->pendingContainerException($containerId, $workflow);
}
throw new PlatformUnavailableException(
message: "Instagram API unreachable: {$e->getMessage()}",
retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS,
maxRetries: self::STATUS_MAX_RETRIES,
);
}
}
/**
* @param array<string, mixed> $workflow
*/
private function pendingContainerException(string $containerId, array $workflow, ?int $httpStatus = null): PlatformUnavailableException
{
return new PlatformUnavailableException(
message: "Instagram is still processing container {$containerId}",
httpStatus: $httpStatus,
context: [PublishCheckpoint::INSTAGRAM_WORKFLOW => $workflow],
retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS,
maxRetries: self::STATUS_MAX_RETRIES,
);
}
/**
* @param array<string, mixed> $parameters
*/
private function createContainer(string $instagramId, array $parameters, string $label): string
{
$response = $this->sendGraphRequest(
fn (): Response => $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $parameters),
);
if ($response->failed()) {
Log::error("Instagram {$label} creation failed", [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
if ($statusResponse->failed()) {
sleep(5);
continue;
}
$status = $statusResponse->json()['status_code'] ?? 'UNKNOWN';
if ($status === 'FINISHED') {
return;
}
if ($status === 'ERROR') {
throw new InstagramPublishException(
userMessage: 'Instagram media processing failed',
category: ErrorCategory::ServerError,
if (GraphError::isTransientFailure($response)) {
throw new PlatformUnavailableException(
message: "Instagram {$label} creation failed transiently",
httpStatus: $response->status(),
retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS,
maxRetries: self::STATUS_MAX_RETRIES,
);
}
sleep(5);
$this->handleApiError($response);
}
Log::warning('Instagram media processing timeout, proceeding anyway');
return $this->requireGraphId(
data_get($response->json(), 'id'),
"Instagram {$label} creation failed: No container ID returned",
);
}
private function requireGraphId(mixed $id, string $missingMessage): string
{
$resolved = $this->stringId($id);
if ($resolved === null) {
throw new InstagramPublishException(
userMessage: $missingMessage,
category: ErrorCategory::ServerError,
);
}
return $resolved;
}
private function stringId(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
/**
* @return list<string>|null
*/
private function stringList(mixed $values): ?array
{
if (! is_array($values)) {
return null;
}
return array_values(array_filter(
$values,
fn (mixed $value): bool => $this->stringId($value) !== null,
));
}
private function handleApiError(Response $response): never

View file

@ -6,12 +6,16 @@
use App\DataTransferObjects\MediaItem;
use App\Enums\SocialAccount\Platform;
use App\Enums\TikTok\PublishStatus;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use App\Support\Social\PublishCheckpoint;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -24,7 +28,9 @@ class TikTokPublisher
{
use HasSocialHttpClient;
private const PHOTO_DERIVATIVE_DIRECTORY = 'social-tiktok-photos';
private const int STATUS_RETRY_DELAY_SECONDS = 30;
private const int STATUS_MAX_RETRIES = 120;
private string $baseUrl;
@ -49,6 +55,16 @@ public function publish(PostPlatform $postPlatform): array
$this->accessToken = $account->access_token;
$pendingPublishId = PublishCheckpoint::tiktokPublishId($postPlatform->error_context);
if ($pendingPublishId !== null) {
return $this->completePublishWithCleanup(
$postPlatform,
$pendingPublishId,
PublishCheckpoint::tiktokDerivativePaths($postPlatform->error_context),
);
}
$media = $postPlatform->post->mediaItems;
if ($media->isEmpty()) {
@ -185,23 +201,11 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte
$data = $response->json();
$publishId = data_get($data, 'data.publish_id');
$publishId = $this->requirePublishId(data_get($data, 'data.publish_id'));
if (! $publishId) {
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}
$this->rememberPublishId($postPlatform, $publishId);
// Wait for processing and get final status
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
return [
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
return $this->completePublish($postPlatform, $publishId);
}
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array
@ -257,28 +261,16 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st
$this->handleApiError($response);
}
$data = $response->json();
$publishId = $this->requirePublishId(data_get($response->json(), 'data.publish_id'));
$publishId = data_get($data, 'data.publish_id');
$this->rememberPublishId($postPlatform, $publishId, $derivatives);
} catch (Throwable $e) {
app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives);
if (! $publishId) {
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}
// Wait for processing and get final status
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
return [
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
} finally {
$this->pruneDerivatives($derivatives);
throw $e;
}
return $this->completePublishWithCleanup($postPlatform, $publishId, $derivatives);
}
/**
@ -345,7 +337,7 @@ private function hostResizedPhoto(string $tempInput): array
$optimized = app(MediaOptimizer::class)->optimizeImage($tempInput, Platform::TikTok);
try {
$path = self::PHOTO_DERIVATIVE_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
$path = TikTokPhotoDerivativeCleaner::DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($optimized));
} finally {
@unlink($optimized);
@ -364,65 +356,124 @@ private function hostResizedPhoto(string $tempInput): array
}
}
/**
* Remove hosted photo derivatives, swallowing storage errors so cleanup can
* never mask the publish result.
*
* @param list<string> $paths
*/
private function pruneDerivatives(array $paths): void
private function waitForPublishStatus(string $publishId): array
{
if ($paths === []) {
return;
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/status/fetch/", [
'publish_id' => $publishId,
]);
if ($response->failed()) {
if ($response->status() !== 429 && ! $response->serverError()) {
$this->handleApiError($response);
}
throw $this->pendingPublishException($publishId, $response->status());
}
$data = $response->json();
$status = PublishStatus::tryFrom((string) data_get($data, 'data.status', ''));
return match ($status) {
PublishStatus::PublishComplete => data_get($data, 'data', []),
PublishStatus::Failed => throw TikTokPublishException::fromFailReason(
(string) data_get($data, 'data.fail_reason', 'Unknown error'),
json_encode($data),
),
default => throw $this->pendingPublishException($publishId),
};
}
private function requirePublishId(mixed $publishId): string
{
$resolved = is_string($publishId) && $publishId !== '' ? $publishId : null;
if ($resolved === null) {
throw new TikTokPublishException(
userMessage: 'TikTok did not return a publish_id',
category: ErrorCategory::ServerError,
);
}
return $resolved;
}
/**
* Persist the publish_id before status polling so a crash after /init/
* can resume without creating a second publish.
*
* @param list<string> $derivatives
*/
private function rememberPublishId(PostPlatform $postPlatform, string $publishId, array $derivatives = []): void
{
$context = [
...($postPlatform->error_context ?? []),
PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId,
];
if ($derivatives !== []) {
$context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives;
}
$postPlatform->update([
'error_context' => $context,
]);
}
private function pendingPublishException(string $publishId, ?int $httpStatus = null): PlatformUnavailableException
{
return new PlatformUnavailableException(
message: "TikTok is still processing publish_id {$publishId}",
httpStatus: $httpStatus,
context: [PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId],
retryDelaySeconds: self::STATUS_RETRY_DELAY_SECONDS,
maxRetries: self::STATUS_MAX_RETRIES,
);
}
/**
* Finish an in-flight publish and prune hosted photos only when TikTok
* confirmed the attempt is dead, or when it completed. Resumable
* interruptions (still processing, expired token, unexpected crash)
* must keep the files so a later status poll can still PULL_FROM_URL.
*
* @param array<array-key, mixed> $derivatives
* @return array<string, mixed>
*/
private function completePublishWithCleanup(PostPlatform $postPlatform, string $publishId, array $derivatives): array
{
$retainDerivatives = true;
try {
Storage::delete($paths);
} catch (Throwable $e) {
Log::warning('Failed to prune TikTok photo derivatives', [
'paths' => $paths,
'exception' => $e->getMessage(),
]);
$result = $this->completePublish($postPlatform, $publishId);
$retainDerivatives = false;
return $result;
} catch (PlatformUnavailableException $e) {
$e->context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives;
throw $e;
} catch (TikTokPublishException $e) {
$retainDerivatives = false;
throw $e;
} finally {
if (! $retainDerivatives) {
app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives);
}
}
}
private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array
private function completePublish(PostPlatform $postPlatform, string $publishId): array
{
for ($i = 0; $i < $maxAttempts; $i++) {
sleep(3);
$statusData = $this->waitForPublishStatus($publishId);
$postId = data_get($statusData, 'publicaly_available_post_id.0');
$postId = is_string($postId) && $postId !== '' ? $postId : null;
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/status/fetch/", [
'publish_id' => $publishId,
]);
if ($response->failed()) {
Log::warning('TikTok status check failed', [
'attempt' => $i,
'body' => $this->redactResponseBody($response->body()),
]);
continue;
}
$data = $response->json();
$status = data_get($data, 'data.status', 'UNKNOWN');
if ($status === 'PUBLISH_COMPLETE') {
return data_get($data, 'data', []);
}
if (in_array($status, ['FAILED', 'PUBLISH_FAILED'])) {
$failReason = data_get($data, 'data.fail_reason', 'Unknown error');
throw TikTokPublishException::fromFailReason($failReason, json_encode($data));
}
// PROCESSING_UPLOAD, PROCESSING_DOWNLOAD, SENDING_TO_USER_INBOX - continue waiting
}
Log::warning('TikTok publish status timeout, returning publish_id anyway');
return ['publish_id' => $publishId];
return [
'id' => $postId ?? $publishId,
'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId),
];
}
private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): ?string

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Support\Social;
/**
* Shared keys and readers for in-flight publish checkpoints on
* PostPlatform.error_context. Used by the publishers, the TikTok
* derivative cleaner, and posts:retry.
*/
final class PublishCheckpoint
{
public const string TIKTOK_PUBLISH_ID = 'tiktok_publish_id';
public const string TIKTOK_DERIVATIVE_PATHS = 'tiktok_derivative_paths';
public const string INSTAGRAM_WORKFLOW = 'instagram_workflow';
/**
* @param array<string, mixed>|null $context
*/
public static function tiktokPublishId(?array $context): ?string
{
$value = data_get($context, self::TIKTOK_PUBLISH_ID);
return is_string($value) && $value !== '' ? $value : null;
}
/**
* @param array<string, mixed>|null $context
* @return array<array-key, mixed>
*/
public static function tiktokDerivativePaths(?array $context): array
{
$paths = data_get($context, self::TIKTOK_DERIVATIVE_PATHS, []);
return is_array($paths) ? $paths : [];
}
/**
* @param array<string, mixed>|null $context
* @return array<string, mixed>|null
*/
public static function instagramWorkflow(?array $context): ?array
{
$workflow = data_get($context, self::INSTAGRAM_WORKFLOW);
return is_array($workflow) && $workflow !== [] ? $workflow : null;
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Support\Social;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;
class TikTokPhotoDerivativeCleaner
{
public const string DIRECTORY = 'social-tiktok-photos';
/**
* @param array<string, mixed>|null $context
*/
public function cleanup(?array $context, ?string $postPlatformId = null): void
{
$this->cleanupPaths(PublishCheckpoint::tiktokDerivativePaths($context), $postPlatformId);
}
/**
* Keep hosted photos while a publish_id can still be resumed.
*
* @param array<string, mixed>|null $context
*/
public function cleanupUnlessPublishInFlight(?array $context, ?string $postPlatformId = null): void
{
if (PublishCheckpoint::tiktokPublishId($context) !== null) {
return;
}
$this->cleanup($context, $postPlatformId);
}
/**
* @param array<array-key, mixed> $paths
*/
public function cleanupPaths(array $paths, ?string $postPlatformId = null): void
{
$derivativePaths = array_values(array_filter(
$paths,
$this->isManagedDerivativePath(...),
));
if ($derivativePaths === []) {
return;
}
try {
Storage::delete($derivativePaths);
} catch (Throwable $e) {
Log::warning('Failed to prune TikTok photo derivatives', [
'post_platform_id' => $postPlatformId,
'error' => $e->getMessage(),
]);
}
}
private function isManagedDerivativePath(mixed $path): bool
{
return is_string($path)
&& dirname($path) === self::DIRECTORY
&& Str::isUuid(pathinfo($path, PATHINFO_FILENAME));
}
}

View file

@ -185,6 +185,8 @@
'video_service_did' => env('BLUESKY_VIDEO_SERVICE_DID', 'did:web:video.bsky.app'),
// Seconds between transcode job-status polls.
'video_poll_seconds' => env('BLUESKY_VIDEO_POLL_SECONDS', 2),
// Gradually back off status checks to at most this interval.
'video_poll_max_seconds' => env('BLUESKY_VIDEO_POLL_MAX_SECONDS', 30),
// Bluesky rejects videos larger than 100 MB; skip oversized files early.
'video_max_bytes' => env('BLUESKY_VIDEO_MAX_BYTES', 100 * 1024 * 1024),
// PLC directory, used to resolve an account's real PDS host from its DID.

View file

@ -14,6 +14,7 @@
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$this->user = User::factory()->create();
@ -139,6 +140,107 @@
->and($post->status)->toBe(PostStatus::Failed);
});
test('it keeps TikTok photo derivatives when recovering a stuck in-flight publish', function () {
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($path, 'image');
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$account = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
]);
$platform = PostPlatform::factory()->tiktok()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_context' => [
'tiktok_publish_id' => 'publish-stuck',
'tiktok_derivative_paths' => [$path],
],
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
Storage::assertExists($path);
expect($platform->fresh()->error_context)->toMatchArray([
'tiktok_publish_id' => 'publish-stuck',
'category' => 'timeout',
]);
});
test('it prunes TikTok photo derivatives when recovering a stuck retry with no publish_id', function () {
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($path, 'image');
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$account = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
]);
$platform = PostPlatform::factory()->tiktok()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_context' => [
'tiktok_derivative_paths' => [$path],
],
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
Storage::assertMissing($path);
expect($platform->fresh()->error_context['category'] ?? null)->toBe('timeout');
});
test('it preserves an Instagram workflow when recovering a stuck retry', function () {
$workflow = [
'stage' => 'final_container',
'container_id' => 'container-stuck',
];
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$account = SocialAccount::factory()->instagram()->create([
'workspace_id' => $this->workspace->id,
]);
$platform = PostPlatform::factory()->instagram()->create([
'post_id' => $post->id,
'social_account_id' => $account->id,
'status' => PlatformStatus::Retrying,
'enabled' => true,
'error_context' => [
'instagram_workflow' => $workflow,
'retry_count' => 40,
],
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
expect($platform->fresh()->status)->toBe(PlatformStatus::Failed)
->and($platform->fresh()->error_context)->toMatchArray([
'instagram_workflow' => $workflow,
'category' => 'timeout',
]);
});
test('it does not finalize a post while a platform is still actively retrying', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,

View file

@ -0,0 +1,812 @@
<?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');
});

View file

@ -21,17 +21,21 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\LinkedInPagePublisher;
use App\Services\Social\LinkedInPublisher;
use App\Services\Social\PinterestPublisher;
use Carbon\Carbon;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Sleep;
beforeEach(function () {
@ -263,7 +267,11 @@
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503)
new PlatformUnavailableException(
'LinkedIn API returned 503 during token refresh',
503,
['operation_id' => 'operation-123'],
)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
@ -277,6 +285,7 @@
expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable');
expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503);
expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(1);
expect($this->postPlatform->error_context['operation_id'] ?? null)->toBe('operation-123');
expect($this->postPlatform->error_message)->toBe(__('posts.errors.platform_unavailable'));
expect($this->postPlatform->error_context['detail'] ?? null)->toContain('LinkedIn API returned 503');
expect($this->socialAccount->status)->toBe(AccountStatus::Connected);
@ -398,6 +407,43 @@
Carbon::setTestNow();
});
test('publish honors a platform-specific retry delay and retry limit', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$now = now()->startOfSecond();
Carbon::setTestNow($now);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new PlatformUnavailableException(
message: 'Remote operation is still processing',
context: ['operation_id' => 'operation-123'],
retryDelaySeconds: 30,
maxRetries: 2,
));
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->error_context['next_attempt_at'] ?? null)
->toBe($now->copy()->addSeconds(30)->toIso8601String());
Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) use ($now) {
return $job->delay instanceof DateTimeInterface
&& Carbon::instance($job->delay)->equalTo($now->copy()->addSeconds(30));
});
$this->postPlatform->update(['error_context' => ['retry_count' => 2]]);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->handle();
expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Failed);
Carbon::setTestNow();
});
test('publish records last_attempt_at when rescheduling for retry', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
@ -422,6 +468,72 @@
Carbon::setTestNow();
});
test('publish preserves resumable context when a later transient error has no context', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$checkpoint = [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
'tiktok_publish_id' => 'publish-123',
'tiktok_derivative_paths' => ['social-tiktok-photos/pending.jpg'],
'retry_count' => 2,
];
$this->postPlatform->update(['error_context' => $checkpoint]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('Token refresh service unavailable', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->handle();
$context = $this->postPlatform->fresh()->error_context;
expect($context['instagram_workflow'] ?? null)->toBe($checkpoint['instagram_workflow'])
->and($context['tiktok_publish_id'] ?? null)->toBe('publish-123')
->and($context['tiktok_derivative_paths'] ?? null)->toBe(['social-tiktok-photos/pending.jpg'])
->and($context['retry_count'] ?? null)->toBe(3)
->and($context['http_status'] ?? null)->toBe(503);
});
test('publish preserves a resumable retry policy after the global retry limit', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
'retry_count' => 7,
'max_retries' => 90,
'retry_delay_seconds' => 10,
],
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('Token refresh service unavailable', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->handle();
$context = $this->postPlatform->fresh()->error_context;
expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Retrying)
->and($context['retry_count'] ?? null)->toBe(8)
->and($context['max_retries'] ?? null)->toBe(90)
->and($context['retry_delay_seconds'] ?? null)->toBe(10);
});
test('post stays in Publishing while one platform is still Retrying', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
@ -533,6 +645,41 @@
Bus::assertNotDispatched(PublishToSocialPlatform::class);
});
test('publish keeps a resumable checkpoint when platform unavailable retries are exhausted', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$workflow = [
'stage' => 'final_container',
'container_id' => 'container-123',
];
$this->postPlatform->update([
'error_context' => [
'tiktok_publish_id' => 'pub_in_flight',
'instagram_workflow' => $workflow,
'retry_count' => PublishToSocialPlatform::MAX_PLATFORM_UNAVAILABLE_RETRIES,
],
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn 503', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->handle();
$context = $this->postPlatform->fresh()->error_context;
expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Failed)
->and($context['tiktok_publish_id'] ?? null)->toBe('pub_in_flight')
->and($context['instagram_workflow'] ?? null)->toBe($workflow)
->and($context['category'] ?? null)->toBe('platform_unavailable');
Bus::assertNotDispatched(PublishToSocialPlatform::class);
});
test('publish skips platforms that are already failed', function () {
Event::fake();
Mail::fake();
@ -576,6 +723,48 @@
->and($job->uniqueFor)->toBe(960);
});
test('publish job prevents concurrent execution across different attempts', function () {
$job = new PublishToSocialPlatform($this->postPlatform, 3);
$middleware = $job->middleware();
expect($middleware)->toHaveCount(1)
->and($middleware[0])->toBeInstanceOf(WithoutOverlapping::class)
->and($middleware[0]->key)->toBe("social-publish:{$this->postPlatform->id}")
->and($middleware[0]->releaseAfter)->toBe(60)
->and($middleware[0]->expiresAfter)->toBe($job->timeout + 60)
->and($job->tries)->toBe(20)
->and($job->maxExceptions)->toBe(1);
});
test('publish job releases an overlapping execution for the same platform', function () {
$runningJob = new PublishToSocialPlatform($this->postPlatform, 0);
$overlappingJob = (new PublishToSocialPlatform($this->postPlatform, 1))->withFakeQueueInteractions();
/** @var WithoutOverlapping $middleware */
$middleware = $overlappingJob->middleware()[0];
$lock = Cache::lock($middleware->getLockKey($runningJob), $runningJob->timeout + 60);
$handled = false;
expect($middleware->getLockKey($runningJob))->toBe($middleware->getLockKey($overlappingJob))
->and($lock->get())->toBeTrue();
try {
$middleware->handle($overlappingJob, function () use (&$handled): void {
$handled = true;
});
} finally {
$lock->release();
}
expect($handled)->toBeFalse();
$overlappingJob->assertReleased(60);
$middleware->handle($overlappingJob, function () use (&$handled): void {
$handled = true;
});
expect($handled)->toBeTrue();
});
test('publish job unique lock drops a duplicate dispatch for the same platform attempt', function () {
Bus::fake([PublishToSocialPlatform::class]);
@ -634,6 +823,226 @@
->and($this->postPlatform->error_message)->toBeNull();
});
test('failed hook keeps TikTok photo derivatives while a publish_id can be resumed', function () {
Event::fake();
Mail::fake();
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
$unrelatedPath = 'customer-media/keep.jpg';
Storage::put($path, 'image');
Storage::put($unrelatedPath, 'image');
$this->postPlatform->update([
'platform' => Platform::TikTok,
'status' => PlatformStatus::Retrying,
'error_context' => [
'tiktok_publish_id' => 'publish-123',
'tiktok_derivative_paths' => [$path, 'social-tiktok-photos/../customer-media/keep.jpg'],
],
]);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->failed(new TypeError('Simulated worker kill'));
Storage::assertExists($path);
Storage::assertExists($unrelatedPath);
expect($this->postPlatform->fresh()->error_context)->toMatchArray([
'tiktok_publish_id' => 'publish-123',
'category' => 'job_failed',
]);
});
test('failed hook prunes TikTok photo derivatives when there is no publish_id', function () {
Event::fake();
Mail::fake();
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($path, 'image');
$this->postPlatform->update([
'platform' => Platform::TikTok,
'status' => PlatformStatus::Retrying,
'error_context' => [
'tiktok_derivative_paths' => [$path],
],
]);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->failed(new TypeError('Simulated worker kill'));
Storage::assertMissing($path);
expect($this->postPlatform->fresh()->error_context['category'] ?? null)->toBe('job_failed');
});
test('terminal TikTok account guards keep derivatives while a publish_id can be resumed', function (string $guard) {
Event::fake();
Mail::fake();
Storage::fake();
$path = 'social-tiktok-photos/'.fake()->uuid().'.jpg';
Storage::put($path, 'image');
$accountAttributes = match ($guard) {
'inactive' => ['is_active' => false],
'disconnected' => ['status' => AccountStatus::Disconnected],
'token_expired' => ['status' => AccountStatus::TokenExpired],
'missing_scopes' => ['scopes' => []],
};
$account = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
...$accountAttributes,
]);
$platform = PostPlatform::factory()->tiktok()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'status' => PlatformStatus::Retrying,
'error_context' => [
'tiktok_publish_id' => "publish-{$guard}",
'tiktok_derivative_paths' => [$path],
],
]);
(new PublishToSocialPlatform($platform))->handle();
Storage::assertExists($path);
$platform->refresh();
expect($platform->status)->toBe(PlatformStatus::Failed)
->and($platform->error_context['tiktok_publish_id'] ?? null)->toBe("publish-{$guard}");
if ($guard === 'missing_scopes') {
expect($platform->error_message)->toBe('Missing permissions: video.publish. Please reconnect your account.')
->and($platform->error_context['category'] ?? null)->toBe('permission')
->and($platform->error_context['missing_scopes'] ?? null)->toBe(['video.publish']);
return;
}
$translationKey = match ($guard) {
'inactive' => 'posts.errors.account_inactive',
'disconnected' => 'posts.errors.account_disconnected',
'token_expired' => 'posts.errors.account_token_expired',
};
expect($platform->error_message)->toBe(__($translationKey));
})->with([
'inactive account' => 'inactive',
'disconnected account' => 'disconnected',
'expired token' => 'token_expired',
'missing publish scopes' => 'missing_scopes',
]);
test('terminal TikTok account guards prune derivatives when there is no publish_id', function (string $guard) {
Event::fake();
Mail::fake();
Storage::fake();
$path = 'social-tiktok-photos/'.fake()->uuid().'.jpg';
Storage::put($path, 'image');
$accountAttributes = match ($guard) {
'inactive' => ['is_active' => false],
'disconnected' => ['status' => AccountStatus::Disconnected],
'token_expired' => ['status' => AccountStatus::TokenExpired],
'missing_scopes' => ['scopes' => []],
};
$account = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
...$accountAttributes,
]);
$platform = PostPlatform::factory()->tiktok()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'status' => PlatformStatus::Retrying,
'error_context' => [
'tiktok_derivative_paths' => [$path],
],
]);
(new PublishToSocialPlatform($platform))->handle();
Storage::assertMissing($path);
expect($platform->fresh()->status)->toBe(PlatformStatus::Failed)
->and($platform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBeNull();
})->with([
'inactive account' => 'inactive',
'disconnected account' => 'disconnected',
'expired token' => 'token_expired',
'missing publish scopes' => 'missing_scopes',
]);
test('tiktok photo publish resumes after a status-fetch token expiry without a second init', function () {
Event::fake();
Mail::fake();
Storage::fake();
$account = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
'username' => 'tiktoker',
'token_expires_at' => now()->addDay(),
]);
$this->post->update([
'media' => [[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
]],
]);
$platform = PostPlatform::factory()->tiktok()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'status' => PlatformStatus::Pending,
'enabled' => true,
'meta' => ['privacy_level' => 'SELF_ONLY'],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
$mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
$verifier = Mockery::mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->once()->andReturn(true);
$this->app->instance(ConnectionVerifier::class, $verifier);
$api = config('trypost.platforms.tiktok.api');
Http::fake([
$api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_job_401']]),
$api.'/post/publish/status/fetch/' => Http::sequence()
->push([
'error' => [
'code' => 'access_token_invalid',
'message' => 'Access token is invalid',
],
], 401)
->push([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publicaly_available_post_id' => ['video_123'],
],
]),
'*' => Http::response('fake-image-content', 200),
]);
(new PublishToSocialPlatform($platform))->handle();
$platform->refresh();
expect($platform->status)->toBe(PlatformStatus::Published)
->and($platform->platform_post_id)->toBe('video_123')
->and($platform->error_context)->toBeNull()
->and(Storage::allFiles('social-tiktok-photos'))->toBeEmpty()
->and(Http::recorded(fn ($request) => str_contains($request->url(), '/post/publish/content/init/')))
->toHaveCount(1);
});
test('pinterest media status 401 marks the account token expired and notifies to reconnect', function () {
Event::fake();
Queue::fake();
@ -990,6 +1399,41 @@
expect($this->postPlatform->error_context['raw_response'])->toBe('{"error": "forbidden"}');
});
test('publish keeps a resumable checkpoint when a later publish exception is terminal', function () {
Event::fake();
$workflow = [
'stage' => 'final_container',
'container_id' => 'container-123',
];
$this->postPlatform->update([
'error_context' => [
'tiktok_publish_id' => 'pub_dead',
'instagram_workflow' => $workflow,
],
]);
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new LinkedInPublishException(
'Video rejected',
ErrorCategory::ContentPolicy,
'video_rejected',
'{"status":"FAILED"}',
)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform->fresh()))->handle();
$context = $this->postPlatform->fresh()->error_context;
expect($this->postPlatform->fresh()->status)->toBe(PlatformStatus::Failed)
->and($context['tiktok_publish_id'] ?? null)->toBe('pub_dead')
->and($context['instagram_workflow'] ?? null)->toBe($workflow)
->and($context['category'] ?? null)->toBe('content_policy');
});
test('publish to social platform fails when scopes are missing', function () {
Event::fake();

View file

@ -16,6 +16,7 @@
use App\Services\Social\LinkCard\LinkCardMetadata;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
beforeEach(function () {
$this->user = User::factory()->create();
@ -1050,6 +1051,114 @@ function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool
Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed']));
});
test('bluesky publisher backs off while video processing remains pending', function () {
attachBlueskyVideo($this->post);
config([
'trypost.platforms.bluesky.video_poll_seconds' => 2,
'trypost.platforms.bluesky.video_poll_max_seconds' => 30,
]);
Sleep::fake();
$statusChecks = 0;
Http::fake(function ($request) use (&$statusChecks) {
$url = $request->url();
if (str_contains($url, 'plc.directory')) {
return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]]);
}
if (str_contains($url, 'getServiceAuth')) {
return Http::response(['token' => 'service-auth-token']);
}
if (str_contains($url, 'app.bsky.video.uploadVideo')) {
return Http::response(['jobId' => 'job-backoff', 'state' => 'JOB_STATE_CREATED']);
}
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
$statusChecks++;
if ($statusChecks === 8) {
return Http::response(['jobStatus' => [
'jobId' => 'job-backoff',
'state' => 'JOB_STATE_COMPLETED',
'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafbackoff'], 'mimeType' => 'video/mp4', 'size' => 2048],
]]);
}
return Http::response(['jobStatus' => ['jobId' => 'job-backoff', 'state' => 'JOB_STATE_RUNNING']]);
}
if (str_contains($url, 'createRecord')) {
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3backoff', 'cid' => 'bafbackoff']);
}
return Http::response(str_repeat('v', 2048));
});
$this->publisher->publish($this->postPlatform);
Sleep::assertSequence([
Sleep::for(2)->seconds(),
Sleep::for(2)->seconds(),
Sleep::for(2)->seconds(),
Sleep::for(4)->seconds(),
Sleep::for(4)->seconds(),
Sleep::for(4)->seconds(),
Sleep::for(8)->seconds(),
]);
});
test('bluesky publisher caps video poll backoff at the configured maximum', function () {
attachBlueskyVideo($this->post);
config([
'trypost.platforms.bluesky.video_poll_seconds' => 10,
'trypost.platforms.bluesky.video_poll_max_seconds' => 30,
]);
Sleep::fake();
$statusChecks = 0;
Http::fake(function ($request) use (&$statusChecks) {
$url = $request->url();
if (str_contains($url, 'plc.directory')) {
return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]]);
}
if (str_contains($url, 'getServiceAuth')) {
return Http::response(['token' => 'service-auth-token']);
}
if (str_contains($url, 'app.bsky.video.uploadVideo')) {
return Http::response(['jobId' => 'job-cap', 'state' => 'JOB_STATE_CREATED']);
}
if (str_contains($url, 'app.bsky.video.getJobStatus')) {
$statusChecks++;
if ($statusChecks === 8) {
return Http::response(['jobStatus' => [
'jobId' => 'job-cap',
'state' => 'JOB_STATE_COMPLETED',
'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafcap'], 'mimeType' => 'video/mp4', 'size' => 2048],
]]);
}
return Http::response(['jobStatus' => ['jobId' => 'job-cap', 'state' => 'JOB_STATE_RUNNING']]);
}
if (str_contains($url, 'createRecord')) {
return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3cap', 'cid' => 'bafcap']);
}
return Http::response(str_repeat('v', 2048));
});
$this->publisher->publish($this->postPlatform);
Sleep::assertSequence([
Sleep::for(10)->seconds(),
Sleep::for(10)->seconds(),
Sleep::for(10)->seconds(),
Sleep::for(20)->seconds(),
Sleep::for(20)->seconds(),
Sleep::for(20)->seconds(),
Sleep::for(30)->seconds(),
]);
});
test('bluesky publisher retries the upload up to three times before giving up', function () {
attachBlueskyVideo($this->post);
config(['trypost.platforms.bluesky.video_poll_seconds' => 0]);

View file

@ -4,6 +4,8 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
@ -13,6 +15,8 @@
use App\Models\Workspace;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\InstagramPublisher;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
@ -441,6 +445,69 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
expect($result['id'])->toBe('carousel-mix-123456789');
});
test('instagram publisher resumes a processing carousel child without recreating child containers', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-image',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test.mp4',
],
],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::sequence()
->push(['id' => 'child-1'], 200)
->push(['id' => 'child-2'], 200)
->push(['id' => 'carousel-container-123'], 200),
'https://graph.instagram.com/v25.0/child-2*' => Http::sequence()
->push(['status_code' => 'IN_PROGRESS'], 200)
->push(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/carousel-container-123*' => Http::response([
'status_code' => 'FINISHED',
], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'id' => 'carousel-resumed-123456789',
], 200),
'https://graph.instagram.com/v25.0/carousel-resumed-123456789*' => Http::response([
'permalink' => 'https://www.instagram.com/p/CAROUSELRESUMED/',
], 200),
]);
try {
$this->publisher->publish($this->postPlatform);
test()->fail('Expected the processing carousel child to be rescheduled.');
} catch (PlatformUnavailableException $exception) {
expect($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'carousel_children',
'child_container_ids' => ['child-1', 'child-2'],
'processing_child_container_ids' => ['child-2'],
],
])->and($exception->retryDelaySeconds)->toBe(10)
->and($exception->maxRetries)->toBe(90);
$this->postPlatform->update(['error_context' => $exception->context]);
}
$result = $this->publisher->publish($this->postPlatform->fresh());
expect($result['id'])->toBe('carousel-resumed-123456789')
->and(collect(Http::recorded())->filter(
fn (array $pair) => $pair[0]->method() === 'POST' && str_ends_with($pair[0]->url(), '/ig_123456789/media')
))->toHaveCount(3);
});
test('instagram publisher throws exception on api error', function () {
$this->post->update([
'media' => [
@ -592,7 +659,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
->toThrow(Exception::class, 'Instagram media processing failed');
});
test('instagram publisher waits for media processing', function () {
test('instagram publisher resumes media processing without creating another container', function () {
$this->post->update([
'media' => [
[
@ -621,9 +688,707 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
try {
$this->publisher->publish($this->postPlatform);
test()->fail('Expected the in-progress container to be rescheduled.');
} catch (PlatformUnavailableException $exception) {
expect($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
])->and($exception->retryDelaySeconds)->toBe(10)
->and($exception->maxRetries)->toBe(90);
$this->postPlatform->update(['error_context' => $exception->context]);
}
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(PlatformUnavailableException::class);
$result = $this->publisher->publish($this->postPlatform->fresh());
expect($result['id'])->toBe('media-123456789');
Http::assertSentCount(6);
expect(collect(Http::recorded())->filter(
fn (array $pair) => $pair[0]->method() === 'POST' && str_ends_with($pair[0]->url(), '/ig_123456789/media')
))->toHaveCount(1);
});
test('instagram publisher does not publish a container that never finishes processing', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'IN_PROGRESS']),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher retries a transient Graph rate-limit on container status', function (int $code) {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'error' => [
'message' => 'Instagram Platform rate limit reached.',
'code' => $code,
],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->httpStatus)->toBe(400)
->and($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
})->with([
'app rate limit' => [4],
'user rate limit' => [17],
'instagram buc' => [80002],
]);
test('instagram publisher retries a transient Graph failure on media_publish', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED']),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response([
'error' => [
'message' => 'An unexpected error has occurred. Please retry your request later.',
'type' => 'OAuthException',
'is_transient' => true,
'code' => 2,
],
], 500),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->httpStatus)->toBe(500)
->and($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
});
test('instagram publisher retries a dropped connection on media_publish', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED']),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
});
test('instagram publisher retries a dropped connection on container status', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher retries a dropped connection on container create', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->getMessage())->toContain('Instagram API unreachable')
->and($exception->context)->toBe([]);
});
});
test('instagram publisher keeps a published media id when the permalink request drops', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED']),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-123456789']),
'https://graph.instagram.com/v25.0/media-123456789*' => fn () => throw new ConnectionException('cURL error 28: Operation timed out'),
]);
expect($this->publisher->publish($this->postPlatform))->toBe([
'id' => 'media-123456789',
'url' => null,
]);
expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([
'stage' => 'final_container',
'container_id' => 'container-123',
'media_id' => 'media-123456789',
]);
});
test('instagram publisher does not publish again when a transient media_publish already landed', function () {
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
Http::fake([
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'PUBLISHED']),
]);
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'container-123',
'url' => null,
]);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher fails a confirmed container status rejection', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response([
'error' => [
'message' => 'The requested resource does not exist',
'type' => 'OAuthException',
'code' => 100,
],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(InstagramPublishException::class);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher rejects an invalid workflow instead of starting over', function (array $workflow) {
$this->postPlatform->update([
'error_context' => ['instagram_workflow' => $workflow],
]);
Http::fake();
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(InstagramPublishException::class, 'Instagram publish state is invalid and cannot be resumed.');
Http::assertNothingSent();
})->with([
'unknown stage' => [['stage' => 'unknown', 'container_id' => 'container-123']],
'final container without id' => [['stage' => 'final_container']],
'carousel without children' => [['stage' => 'carousel_children', 'child_container_ids' => []]],
]);
test('instagram publisher fails a resumed container that reports ERROR', function () {
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
Http::fake([
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'ERROR'], 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(InstagramPublishException::class, 'Instagram media processing failed');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
Http::assertNotSent(fn ($request) => $request->method() === 'POST' && str_contains($request->url(), '/media'));
});
test('instagram publisher fails an expired container instead of retrying it', function () {
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
Http::fake([
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'EXPIRED'], 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(function (InstagramPublishException $exception): void {
expect($exception->userMessage)->toBe('Media container expired. Please try again in a few minutes.')
->and($exception->category)->toBe(ErrorCategory::ServerError);
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
Http::assertNotSent(fn ($request) => $request->method() === 'POST' && str_contains($request->url(), '/media'));
});
test('instagram publisher completes a published container without calling media_publish', function () {
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
Http::fake(function (Request $request) {
if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) {
return Http::response(['status_code' => 'PUBLISHED'], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) {
return Http::response([
'data' => [[
'id' => 'other-account-post',
'permalink' => 'https://www.instagram.com/p/WRONG/',
]],
], 200);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'container-123',
'url' => null,
]);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/ig_123456789/media'));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});
test('instagram publisher does not bind another account post when a published story has no media id', function () {
$this->postPlatform->update([
'content_type' => ContentType::InstagramStory,
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
Http::fake(function (Request $request) {
if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) {
return Http::response(['status_code' => 'PUBLISHED'], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/stories')) {
return Http::response([
'data' => [[
'id' => 'other-story',
'permalink' => 'https://www.instagram.com/stories/testuser/1/',
]],
], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) {
return Http::response([
'data' => [[
'id' => 'feed-must-not-be-used',
'permalink' => 'https://www.instagram.com/p/FEED/',
]],
], 200);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'container-123',
'url' => null,
]);
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/stories'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/media'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher does not bind another reel from /media when published without a media id', function () {
$this->postPlatform->update([
'content_type' => ContentType::InstagramReel,
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
Http::fake(function (Request $request) {
if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) {
return Http::response(['status_code' => 'PUBLISHED'], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) {
return Http::response([
'data' => [[
'id' => 'other-reel',
'permalink' => 'https://www.instagram.com/reel/WRONG/',
]],
], 200);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'container-123',
'url' => null,
]);
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/media'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/stories'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher keeps a published carousel parent id without listing /media', function () {
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'carousel_children',
'child_container_ids' => ['child-1', 'child-2'],
'processing_child_container_ids' => ['child-2'],
],
],
]);
Http::fake(function (Request $request) {
if ($request->method() === 'GET' && str_contains($request->url(), '/child-2')) {
return Http::response(['status_code' => 'FINISHED'], 200);
}
if ($request->method() === 'POST' && str_ends_with(explode('?', $request->url())[0], '/ig_123456789/media')) {
return Http::response(['id' => 'carousel-parent-123'], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/carousel-parent-123')) {
return Http::response(['status_code' => 'PUBLISHED'], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) {
return Http::response([
'data' => [[
'id' => 'other-carousel',
'permalink' => 'https://www.instagram.com/p/WRONG/',
]],
], 200);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'carousel-parent-123',
'url' => null,
]);
Http::assertSent(fn (Request $request) => $request->method() === 'POST' && str_contains($request->url(), '/ig_123456789/media'));
Http::assertNotSent(fn (Request $request) => $request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher resumes a checkpointed media id without listing recent media', function () {
$this->postPlatform->update([
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
'media_id' => 'media-persisted',
],
],
]);
Http::fake(function (Request $request) {
if ($request->method() === 'GET' && str_contains($request->url(), '/media-persisted')) {
return Http::response([
'permalink' => 'https://www.instagram.com/p/PERSISTED/',
], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_123456789/media')) {
return Http::response([
'data' => [[
'id' => 'other-account-post',
'permalink' => 'https://www.instagram.com/p/WRONG/',
]],
], 200);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'media-persisted',
'url' => 'https://www.instagram.com/p/PERSISTED/',
]);
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/container-123'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_123456789/media'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher checkpoints the media id before fetching the permalink', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123'], 200),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['status_code' => 'FINISHED'], 200),
'https://graph.instagram.com/v25.0/ig_123456789/media_publish' => Http::response(['id' => 'media-123456789'], 200),
'https://graph.instagram.com/v25.0/media-123456789*' => Http::response(['error' => ['message' => 'temporarily unavailable']], 500),
]);
expect($this->publisher->publish($this->postPlatform))->toBe([
'id' => 'media-123456789',
'url' => null,
]);
expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([
'stage' => 'final_container',
'container_id' => 'container-123',
'media_id' => 'media-123456789',
]);
});
test('instagram facebook publisher recovers a published container on graph.facebook.com', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::InstagramFacebook,
'platform_user_id' => 'ig_fb_123',
'access_token' => 'page_token_123',
'token_expires_at' => null,
'scopes' => Platform::InstagramFacebook->requiredPublishScopes(),
]);
$this->postPlatform->update([
'social_account_id' => $account->id,
'platform' => Platform::InstagramFacebook,
'error_context' => [
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
],
]);
$graph = (string) config('trypost.platforms.instagram-facebook.graph_api');
Http::fake(function (Request $request) use ($graph) {
expect($request->url())->toStartWith($graph)
->and($request->url())->not->toContain('graph.instagram.com');
if ($request->method() === 'GET' && str_contains($request->url(), '/container-123')) {
return Http::response(['status_code' => 'PUBLISHED'], 200);
}
if ($request->method() === 'GET' && str_contains($request->url(), '/ig_fb_123/media')) {
return Http::response([
'data' => [[
'id' => 'other-facebook-post',
'permalink' => 'https://www.instagram.com/p/WRONG/',
]],
], 200);
}
return Http::response(['error' => ['message' => 'unexpected']], 500);
});
expect($this->publisher->publish($this->postPlatform->fresh()))->toBe([
'id' => 'container-123',
'url' => null,
]);
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), 'graph.instagram.com'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/ig_fb_123/media'));
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher retries a 5xx on container status without publishing', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['error' => ['message' => 'Service temporarily unavailable']], 503),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->httpStatus)->toBe(503)
->and($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher retries a 429 on container status without publishing', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
]],
]);
Http::fake([
'https://graph.instagram.com/v25.0/ig_123456789/media' => Http::response(['id' => 'container-123']),
'https://graph.instagram.com/v25.0/container-123*' => Http::response(['error' => ['message' => 'Application request limit reached', 'code' => 4]], 429),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->httpStatus)->toBe(429)
->and($exception->context)->toBe([
'instagram_workflow' => [
'stage' => 'final_container',
'container_id' => 'container-123',
],
]);
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/media_publish'));
});
test('instagram publisher throws exception when all carousel items fail', function () {

View file

@ -4,6 +4,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
@ -87,6 +88,340 @@
});
});
test('tiktok publisher does not report success before processing completes', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
]],
]);
Http::fake([
$this->api.'/post/publish/video/init/' => Http::response(['data' => ['publish_id' => 'pub_processing']]),
$this->api.'/post/publish/status/fetch/' => Http::response(['data' => ['status' => 'PROCESSING_DOWNLOAD']]),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->context)->toBe(['tiktok_publish_id' => 'pub_processing'])
->and($exception->retryDelaySeconds)->toBe(30)
->and($exception->maxRetries)->toBe(120);
});
expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_processing');
Http::assertSentCount(2);
});
test('tiktok publisher checkpoints a video publish_id when status fetch reports an expired token', function () {
$this->post->update([
'media' => [[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
]],
]);
Http::fake([
$this->api.'/post/publish/video/init/' => Http::response(['data' => ['publish_id' => 'pub_video_401']]),
$this->api.'/post/publish/status/fetch/' => Http::response([
'error' => [
'code' => 'access_token_invalid',
'message' => 'Access token is invalid',
],
], 401),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_video_401');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/content/init/'));
});
test('tiktok publisher checkpoints a photo publish_id before polling status', function () {
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [[
'id' => 'test-media-image',
'path' => 'media/2026-01/image1.jpg',
'url' => 'https://example.com/media/2026-01/image1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image1.jpg',
'meta' => ['width' => 1080, 'height' => 1080],
]],
]);
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_processing']]),
$this->api.'/post/publish/status/fetch/' => Http::response(['data' => ['status' => 'PROCESSING_DOWNLOAD']]),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(PlatformUnavailableException::class);
expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_photo_processing');
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/init/'));
});
test('tiktok publisher checkpoints photo derivatives with the publish_id before polling', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
]],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
$mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_deriv']]),
$this->api.'/post/publish/status/fetch/' => Http::response(['data' => ['status' => 'PROCESSING_DOWNLOAD']]),
'*' => Http::response('fake-image-content', 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(PlatformUnavailableException::class);
$context = $this->postPlatform->fresh()->error_context;
$paths = $context['tiktok_derivative_paths'] ?? null;
expect($context['tiktok_publish_id'] ?? null)->toBe('pub_photo_deriv')
->and($paths)->toBeArray()
->and($paths)->not->toBeEmpty();
foreach ($paths as $path) {
Storage::assertExists($path);
}
});
test('tiktok publisher keeps photo derivatives when status fetch reports an expired token', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
]],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
$mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_401']]),
$this->api.'/post/publish/status/fetch/' => Http::sequence()
->push([
'error' => [
'code' => 'access_token_invalid',
'message' => 'Access token is invalid',
],
], 401)
->push([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publicaly_available_post_id' => ['video_123'],
],
]),
'*' => Http::response('fake-image-content', 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
$context = $this->postPlatform->fresh()->error_context;
$paths = $context['tiktok_derivative_paths'] ?? null;
expect($context['tiktok_publish_id'] ?? null)->toBe('pub_photo_401')
->and($paths)->toBeArray()
->and($paths)->not->toBeEmpty();
foreach ($paths as $path) {
Storage::assertExists($path);
}
$result = $this->publisher->publish($this->postPlatform->fresh());
expect($result['id'])->toBe('video_123');
foreach ($paths as $path) {
Storage::assertMissing($path);
}
expect(Http::recorded(fn ($request) => str_contains($request->url(), '/post/publish/content/init/')))
->toHaveCount(1);
});
test('tiktok publisher prunes photo derivatives when TikTok confirms the publish failed', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [[
'id' => 'oversized',
'path' => 'media/2026-01/big.jpg',
'url' => 'https://example.com/media/2026-01/big.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'big.jpg',
'meta' => ['width' => 1254, 'height' => 1254],
]],
]);
$mockOptimizer = Mockery::mock(MediaOptimizer::class);
$mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080);
$mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) {
$optimized = tempnam(sys_get_temp_dir(), 'tt_opt_');
copy($tempFile, $optimized);
return $optimized;
});
app()->instance(MediaOptimizer::class, $mockOptimizer);
Http::fake([
$this->api.'/post/publish/content/init/' => Http::response(['data' => ['publish_id' => 'pub_photo_failed']]),
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'FAILED',
'fail_reason' => 'photo_pull_failed',
],
]),
'*' => Http::response('fake-image-content', 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TikTokPublishException::class);
expect($this->postPlatform->fresh()->error_context['tiktok_publish_id'] ?? null)->toBe('pub_photo_failed')
->and(Storage::allFiles('social-tiktok-photos'))->toBeEmpty();
});
test('tiktok publisher resumes an existing publish without creating a duplicate', function () {
$this->postPlatform->update([
'error_context' => ['tiktok_publish_id' => 'pub_existing'],
]);
Http::fake([
$this->api.'/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publicaly_available_post_id' => ['video_123'],
],
]),
]);
$result = $this->publisher->publish($this->postPlatform->fresh());
expect($result)->toBe([
'id' => 'video_123',
'url' => 'https://www.tiktok.com/@tiktoker/video/video_123',
]);
Http::assertSentCount(1);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/'));
});
test('tiktok publisher retries a server-error status fetch without creating a duplicate', function () {
$this->postPlatform->update([
'error_context' => ['tiktok_publish_id' => 'pub_existing'],
]);
Http::fake([
$this->api.'/post/publish/status/fetch/' => Http::response(['error' => ['code' => 'internal_error']], 503),
]);
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->httpStatus)->toBe(503)
->and($exception->context['tiktok_publish_id'] ?? null)->toBe('pub_existing');
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/'));
});
test('tiktok publisher retries a rate-limited status fetch without creating a duplicate', function () {
$this->postPlatform->update([
'error_context' => ['tiktok_publish_id' => 'pub_existing'],
]);
Http::fake([
$this->api.'/post/publish/status/fetch/' => Http::response(['error' => ['code' => 'rate_limit']], 429),
]);
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(function (PlatformUnavailableException $exception): void {
expect($exception->httpStatus)->toBe(429)
->and($exception->context['tiktok_publish_id'] ?? null)->toBe('pub_existing');
});
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/'));
});
test('tiktok publisher keeps photo derivatives while pending and prunes them on completion', function () {
Storage::fake();
$derivativePath = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($derivativePath, 'image');
$this->postPlatform->update([
'error_context' => [
'tiktok_publish_id' => 'pub_existing',
'tiktok_derivative_paths' => [$derivativePath],
],
]);
Http::fake([
$this->api.'/post/publish/status/fetch/' => Http::sequence()
->push(['data' => ['status' => 'PROCESSING_DOWNLOAD']])
->push(['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['video_123']]]),
]);
expect(fn () => $this->publisher->publish($this->postPlatform->fresh()))
->toThrow(function (PlatformUnavailableException $exception) use ($derivativePath): void {
expect($exception->context['tiktok_derivative_paths'] ?? null)->toBe([$derivativePath]);
});
Storage::assertExists($derivativePath);
$result = $this->publisher->publish($this->postPlatform->fresh());
expect($result['id'])->toBe('video_123');
Storage::assertMissing($derivativePath);
Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/'));
});
test('tiktok publisher can publish photos', function () {
$this->post->update([
'media' => [

View file

@ -76,11 +76,39 @@ public function platform(): string
});
test('error category enum has all expected cases', function () {
expect(ErrorCategory::cases())->toHaveCount(6)
expect(ErrorCategory::cases())->toHaveCount(10)
->and(ErrorCategory::MediaFormat->value)->toBe('media_format')
->and(ErrorCategory::RateLimit->value)->toBe('rate_limit')
->and(ErrorCategory::Permission->value)->toBe('permission')
->and(ErrorCategory::ContentPolicy->value)->toBe('content_policy')
->and(ErrorCategory::ServerError->value)->toBe('server_error')
->and(ErrorCategory::Unknown->value)->toBe('unknown');
->and(ErrorCategory::Unknown->value)->toBe('unknown')
->and(ErrorCategory::PlatformUnavailable->value)->toBe('platform_unavailable')
->and(ErrorCategory::Timeout->value)->toBe('timeout')
->and(ErrorCategory::TokenExpired->value)->toBe('token_expired')
->and(ErrorCategory::JobFailed->value)->toBe('job_failed');
});
test('error category marks only in-flight interruptions as resumable', function (ErrorCategory $category, bool $resumable) {
expect($category->isResumable())->toBe($resumable);
})->with([
'platform unavailable' => [ErrorCategory::PlatformUnavailable, true],
'timeout' => [ErrorCategory::Timeout, true],
'token expired' => [ErrorCategory::TokenExpired, true],
'job failed' => [ErrorCategory::JobFailed, true],
'media format' => [ErrorCategory::MediaFormat, false],
'content policy' => [ErrorCategory::ContentPolicy, false],
'server error' => [ErrorCategory::ServerError, false],
'permission' => [ErrorCategory::Permission, false],
'rate limit' => [ErrorCategory::RateLimit, false],
'unknown' => [ErrorCategory::Unknown, false],
]);
test('error category tryFromContext reads a stored category', function (?array $context, ?ErrorCategory $expected) {
expect(ErrorCategory::tryFromContext($context))->toBe($expected);
})->with([
'resumable' => [['category' => 'token_expired'], ErrorCategory::TokenExpired],
'unknown string' => [['category' => 'not-a-category'], null],
'missing' => [[], null],
'null context' => [null, null],
]);

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Support\Social\PublishCheckpoint;
test('tiktokPublishId reads a non-empty checkpoint', function () {
expect(PublishCheckpoint::tiktokPublishId([
PublishCheckpoint::TIKTOK_PUBLISH_ID => 'pub_in_flight',
]))->toBe('pub_in_flight')
->and(PublishCheckpoint::tiktokPublishId(['tiktok_publish_id' => '']))->toBeNull()
->and(PublishCheckpoint::tiktokPublishId(null))->toBeNull();
});
test('tiktokDerivativePaths returns an array or an empty list', function () {
expect(PublishCheckpoint::tiktokDerivativePaths([
PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS => ['social-tiktok-photos/a.jpg'],
]))->toBe(['social-tiktok-photos/a.jpg'])
->and(PublishCheckpoint::tiktokDerivativePaths(['tiktok_derivative_paths' => 'invalid']))->toBe([])
->and(PublishCheckpoint::tiktokDerivativePaths(null))->toBe([]);
});
test('instagramWorkflow returns a non-empty array when present', function () {
$workflow = ['stage' => 'final_container', 'container_id' => 'c1'];
expect(PublishCheckpoint::instagramWorkflow([
PublishCheckpoint::INSTAGRAM_WORKFLOW => $workflow,
]))->toBe($workflow)
->and(PublishCheckpoint::instagramWorkflow(['instagram_workflow' => []]))->toBeNull()
->and(PublishCheckpoint::instagramWorkflow(null))->toBeNull();
});

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Support\Social\TikTokPhotoDerivativeCleaner;
use Illuminate\Support\Facades\Storage;
test('it deletes only managed TikTok photo derivatives', function () {
Storage::fake();
$managedPaths = [
'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg',
'social-tiktok-photos/223e4567-e89b-12d3-a456-426614174000.webp',
];
$unmanagedPaths = [
'customer-media/123e4567-e89b-12d3-a456-426614174000.jpg',
'social-tiktok-photos/nested/123e4567-e89b-12d3-a456-426614174000.jpg',
'social-tiktok-photos/../customer-media/123e4567-e89b-12d3-a456-426614174000.jpg',
'social-tiktok-photos/not-a-uuid.jpg',
];
foreach ([...$managedPaths, ...$unmanagedPaths] as $path) {
Storage::put($path, 'image');
}
app(TikTokPhotoDerivativeCleaner::class)->cleanup([
'tiktok_derivative_paths' => [...$managedPaths, ...$unmanagedPaths, null, 123],
]);
Storage::assertMissing($managedPaths);
Storage::assertExists($unmanagedPaths);
});
test('it keeps derivatives while a publish_id is still in flight', function () {
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($path, 'image');
app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight([
'tiktok_publish_id' => 'pub_in_flight',
'tiktok_derivative_paths' => [$path],
]);
Storage::assertExists($path);
});
test('it prunes derivatives when there is no publish_id to resume', function () {
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($path, 'image');
app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight([
'tiktok_derivative_paths' => [$path],
]);
Storage::assertMissing($path);
});
test('it prunes derivatives when the publish_id is an empty string', function () {
Storage::fake();
$path = 'social-tiktok-photos/123e4567-e89b-12d3-a456-426614174000.jpg';
Storage::put($path, 'image');
app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight([
'tiktok_publish_id' => '',
'tiktok_derivative_paths' => [$path],
]);
Storage::assertMissing($path);
});
test('it ignores invalid retry context', function () {
Storage::fake();
$cleaner = app(TikTokPhotoDerivativeCleaner::class);
$cleaner->cleanup(null);
$cleaner->cleanup([]);
$cleaner->cleanup(['tiktok_derivative_paths' => 'invalid']);
Storage::assertDirectoryEmpty('/');
});

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Enums\TikTok\PublishStatus;
test('publish status mirrors the tiktok status fetch values', function () {
expect(PublishStatus::cases())->toHaveCount(5)
->and(PublishStatus::ProcessingUpload->value)->toBe('PROCESSING_UPLOAD')
->and(PublishStatus::ProcessingDownload->value)->toBe('PROCESSING_DOWNLOAD')
->and(PublishStatus::SendToUserInbox->value)->toBe('SEND_TO_USER_INBOX')
->and(PublishStatus::PublishComplete->value)->toBe('PUBLISH_COMPLETE')
->and(PublishStatus::Failed->value)->toBe('FAILED');
});
test('publish status tryFrom accepts known values and rejects unknown', function (string $value, ?PublishStatus $expected) {
expect(PublishStatus::tryFrom($value))->toBe($expected);
})->with([
['PROCESSING_UPLOAD', PublishStatus::ProcessingUpload],
['PROCESSING_DOWNLOAD', PublishStatus::ProcessingDownload],
['SEND_TO_USER_INBOX', PublishStatus::SendToUserInbox],
['PUBLISH_COMPLETE', PublishStatus::PublishComplete],
['FAILED', PublishStatus::Failed],
['PUBLISH_FAILED', null],
['UNKNOWN', null],
['', null],
]);