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:
parent
2f6c006bfb
commit
4546425532
23 changed files with 3749 additions and 328 deletions
|
|
@ -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.
|
||||
|
|
|
|||
202
app/Console/Commands/RetryFailedPost.php
Normal file
202
app/Console/Commands/RetryFailedPost.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
app/Enums/Instagram/ContainerStatus.php
Normal file
19
app/Enums/Instagram/ContainerStatus.php
Normal 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';
|
||||
}
|
||||
19
app/Enums/TikTok/PublishStatus.php
Normal file
19
app/Enums/TikTok/PublishStatus.php
Normal 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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
51
app/Support/Social/PublishCheckpoint.php
Normal file
51
app/Support/Social/PublishCheckpoint.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
app/Support/Social/TikTokPhotoDerivativeCleaner.php
Normal file
68
app/Support/Social/TikTokPhotoDerivativeCleaner.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
812
tests/Feature/Commands/RetryFailedPostTest.php
Normal file
812
tests/Feature/Commands/RetryFailedPostTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
31
tests/Unit/Support/Social/PublishCheckpointTest.php
Normal file
31
tests/Unit/Support/Social/PublishCheckpointTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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('/');
|
||||
});
|
||||
27
tests/Unit/TikTok/PublishStatusTest.php
Normal file
27
tests/Unit/TikTok/PublishStatusTest.php
Normal 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],
|
||||
]);
|
||||
Loading…
Reference in a new issue