feat: publishing hardening — content sanitization, validation, scopes, stuck recovery

- Add ContentSanitizer: strips HTML, converts bold/underline to Unicode for LinkedIn
- Add backend content length validation in all 11 publishers via HasSocialHttpClient trait
- Add scope verification before publishing — checks required scopes per platform
- Add RecoverStuckPosts command (every 30min) — recovers posts stuck in publishing > 1h
- Add requiredPublishScopes() to Platform enum
- Update SocialAccount factory with correct scopes per platform state
This commit is contained in:
Paulo Castellano 2026-04-01 11:25:25 -03:00
parent 8cbc372d91
commit e034c0b572
22 changed files with 509 additions and 33 deletions

View file

@ -0,0 +1,59 @@
<?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\Models\Post;
use Illuminate\Console\Command;
class RecoverStuckPosts extends Command
{
protected $signature = 'social:recover-stuck-posts';
protected $description = 'Recover posts stuck in publishing status for more than 1 hour';
public function handle(): void
{
$count = 0;
Post::query()
->where('status', PostStatus::Publishing)
->where('updated_at', '<=', now()->subHour())
->each(function (Post $post) use (&$count) {
// Mark stuck platforms as failed
$post->postPlatforms()
->where('enabled', true)
->where('status', PlatformStatus::Publishing)
->where('updated_at', '<=', now()->subHour())
->update([
'status' => PlatformStatus::Failed,
'error_message' => 'Publishing timed out. Please try again.',
'error_context' => json_encode([
'category' => 'timeout',
'failed_at' => now()->toIso8601String(),
]),
]);
// Recalculate post status
$enabledPlatforms = $post->postPlatforms()->where('enabled', true)->get();
$total = $enabledPlatforms->count();
$publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count();
$failedCount = $enabledPlatforms->where('status', PlatformStatus::Failed)->count();
if ($publishedCount === $total) {
$post->markAsPublished();
} elseif ($publishedCount > 0) {
$post->markAsPartiallyPublished();
} else {
$post->markAsFailed();
}
$count++;
});
$this->info("Recovered {$count} stuck posts.");
}
}

View file

@ -101,6 +101,26 @@ public function maxContentLength(): int
};
}
/**
* @return array<string>
*/
public function requiredPublishScopes(): array
{
return match ($this) {
self::Instagram => ['instagram_business_content_publish'],
self::Facebook => ['pages_manage_posts'],
self::TikTok => ['video.publish'],
self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'],
self::LinkedIn => ['w_member_social'],
self::LinkedInPage => ['w_organization_social'],
self::X => ['tweet.write'],
self::Threads => ['threads_content_publish'],
self::Pinterest => ['pins:write'],
self::Bluesky => [],
self::Mastodon => ['write:statuses'],
};
}
public function supportsTextOnly(): bool
{
return match ($this) {

View file

@ -69,6 +69,24 @@ public function handle(): void
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;
}
}
$this->postPlatform->markAsPublishing();
$this->broadcastStatus();

View file

@ -21,6 +21,10 @@ class BlueskyPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? 'https://bsky.social';
@ -57,7 +61,7 @@ public function publish(PostPlatform $postPlatform): array
}
// Parse facets (links, mentions, hashtags) from text
$text = $postPlatform->content ?? '';
$text = $content ?? '';
$facets = $this->parseFacets($text);
// Create post record

View file

@ -4,11 +4,24 @@
namespace App\Services\Social\Concerns;
use App\Models\PostPlatform;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
trait HasSocialHttpClient
{
protected function validateContentLength(PostPlatform $postPlatform): void
{
$maxLength = $postPlatform->platform->maxContentLength();
$contentLength = mb_strlen($postPlatform->content ?? '');
if ($contentLength > $maxLength) {
throw new \Exception(
"Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)."
);
}
}
protected function socialHttp(): PendingRequest
{
return Http::retry(

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
class ContentSanitizer
{
public function sanitize(string $content, Platform $platform): string
{
return match ($platform) {
Platform::LinkedIn, Platform::LinkedInPage => $this->convertBoldAndStrip($content),
default => $this->stripHtml($content),
};
}
private function stripHtml(string $content): string
{
// Convert <p> tags to newlines
$content = preg_replace('/<p[^>]*>/i', '', $content);
$content = str_replace('</p>', "\n", $content);
// Convert <br> to newlines
$content = preg_replace('/<br\s*\/?>/i', "\n", $content);
// Convert list items to dash prefix
$content = preg_replace('/<li[^>]*>/i', '- ', $content);
$content = str_replace('</li>', "\n", $content);
// Strip remaining HTML tags
$content = strip_tags($content);
// Decode HTML entities
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Clean up excessive newlines (max 2 consecutive)
$content = preg_replace("/\n{3,}/", "\n\n", $content);
return trim($content);
}
private function convertBoldAndStrip(string $content): string
{
// Convert <strong>/<b> to Unicode bold characters for LinkedIn
$content = preg_replace_callback(
'/<(?:strong|b)>(.*?)<\/(?:strong|b)>/si',
fn ($matches) => $this->toUnicodeBold($matches[1]),
$content
);
// Convert <u> to Unicode underline
$content = preg_replace_callback(
'/<u>(.*?)<\/u>/si',
fn ($matches) => $this->toUnicodeUnderline($matches[1]),
$content
);
return $this->stripHtml($content);
}
private function toUnicodeBold(string $text): string
{
$map = [
'a' => '𝗮', 'b' => '𝗯', 'c' => '𝗰', 'd' => '𝗱', 'e' => '𝗲',
'f' => '𝗳', 'g' => '𝗴', 'h' => '𝗵', 'i' => '𝗶', 'j' => '𝗷',
'k' => '𝗸', 'l' => '𝗹', 'm' => '𝗺', 'n' => '𝗻', 'o' => '𝗼',
'p' => '𝗽', 'q' => '𝗾', 'r' => '𝗿', 's' => '𝘀', 't' => '𝘁',
'u' => '𝘂', 'v' => '𝘃', 'w' => '𝘄', 'x' => '𝘅', 'y' => '𝘆', 'z' => '𝘇',
'A' => '𝗔', 'B' => '𝗕', 'C' => '𝗖', 'D' => '𝗗', 'E' => '𝗘',
'F' => '𝗙', 'G' => '𝗚', 'H' => '𝗛', 'I' => '𝗜', 'J' => '𝗝',
'K' => '𝗞', 'L' => '𝗟', 'M' => '𝗠', 'N' => '𝗡', 'O' => '𝗢',
'P' => '𝗣', 'Q' => '𝗤', 'R' => '𝗥', 'S' => '𝗦', 'T' => '𝗧',
'U' => '𝗨', 'V' => '𝗩', 'W' => '𝗪', 'X' => '𝗫', 'Y' => '𝗬', 'Z' => '𝗭',
'0' => '𝟬', '1' => '𝟭', '2' => '𝟮', '3' => '𝟯', '4' => '𝟰',
'5' => '𝟱', '6' => '𝟲', '7' => '𝟳', '8' => '𝟴', '9' => '𝟵',
];
return strtr($text, $map);
}
private function toUnicodeUnderline(string $text): string
{
// Unicode combining underline character
return implode('', array_map(
fn ($char) => $char."\u{0332}",
mb_str_split($text)
));
}
}

View file

@ -20,6 +20,10 @@ class FacebookPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$pageId = $account->platform_user_id;
$accessToken = $account->access_token;
@ -28,9 +32,9 @@ public function publish(PostPlatform $postPlatform): array
$contentType = $postPlatform->content_type;
return match ($contentType) {
ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $postPlatform->content, $media->first()),
ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()),
ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()),
ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $postPlatform->content, $media),
ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media),
default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"),
};
}

View file

@ -22,6 +22,8 @@ class InstagramPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
@ -32,6 +34,8 @@ public function publish(PostPlatform $postPlatform): array
$instagramId = $account->platform_user_id;
$accessToken = $account->access_token;
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$media = $postPlatform->media;
if ($media->isEmpty()) {
@ -42,9 +46,9 @@ public function publish(PostPlatform $postPlatform): array
$contentType = $postPlatform->content_type;
return match ($contentType) {
ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia),
ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $content, $firstMedia),
ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia),
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $postPlatform->content, $media),
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $content, $media),
default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"),
};
}

View file

@ -33,6 +33,10 @@ class LinkedInPagePublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
@ -54,16 +58,16 @@ public function publish(PostPlatform $postPlatform): array
try {
return match ($contentType) {
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account),
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
};
} catch (TokenExpiredException $e) {
return $this->retryWithRefresh($postPlatform, $e);
return $this->retryWithRefresh($postPlatform, $content, $e);
}
}
private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
{
if ($this->hasRetried) {
throw $originalException;
@ -81,8 +85,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept
$contentType = $postPlatform->content_type;
return match ($contentType) {
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account),
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account),
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
};
} catch (\Throwable $e) {

View file

@ -33,6 +33,10 @@ class LinkedInPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$this->account = $postPlatform->socialAccount;
$this->hasRetried = false;
@ -48,16 +52,16 @@ public function publish(PostPlatform $postPlatform): array
try {
return match ($contentType) {
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media),
ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media),
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media),
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
};
} catch (TokenExpiredException $e) {
return $this->retryWithRefresh($postPlatform, $e);
return $this->retryWithRefresh($postPlatform, $content, $e);
}
}
private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
{
if ($this->hasRetried) {
throw $originalException;
@ -74,8 +78,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept
$contentType = $postPlatform->content_type;
return match ($contentType) {
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media),
ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media),
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media),
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media),
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
};
} catch (\Throwable $e) {

View file

@ -20,6 +20,8 @@ class MastodonPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
$instance = $account->meta['instance'] ?? 'https://mastodon.social';

View file

@ -23,6 +23,8 @@ class PinterestPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {

View file

@ -20,6 +20,10 @@ class ThreadsPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
@ -34,11 +38,11 @@ public function publish(PostPlatform $postPlatform): array
// Text only post
if ($media->isEmpty()) {
if (empty($postPlatform->content)) {
if (empty($content)) {
throw new \Exception('Threads text posts require content. Please add text to your post.');
}
return $this->publishTextPost($userId, $accessToken, $postPlatform->content);
return $this->publishTextPost($userId, $accessToken, $content);
}
$firstMedia = $media->first();
@ -47,14 +51,14 @@ public function publish(PostPlatform $postPlatform): array
// Single media
if ($media->count() === 1) {
if ($isVideo) {
return $this->publishVideoPost($userId, $accessToken, $postPlatform->content, $firstMedia);
return $this->publishVideoPost($userId, $accessToken, $content, $firstMedia);
}
return $this->publishImagePost($userId, $accessToken, $postPlatform->content, $firstMedia);
return $this->publishImagePost($userId, $accessToken, $content, $firstMedia);
}
// Multiple media - carousel
return $this->publishCarousel($userId, $accessToken, $postPlatform->content, $media);
return $this->publishCarousel($userId, $accessToken, $content, $media);
}
private function publishTextPost(string $userId, string $accessToken, string $content): array

View file

@ -24,6 +24,10 @@ class TikTokPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
@ -44,11 +48,11 @@ public function publish(PostPlatform $postPlatform): array
$isImage = $firstMedia->isImage();
if ($isVideo) {
return $this->publishVideo($postPlatform, $firstMedia);
return $this->publishVideo($postPlatform, $firstMedia, $content);
}
if ($isImage) {
return $this->publishPhotos($postPlatform, $media);
return $this->publishPhotos($postPlatform, $media, $content);
}
throw new \Exception('TikTok only supports video or image content.');
@ -93,14 +97,14 @@ private function queryCreatorInfo(): array
];
}
private function publishVideo(PostPlatform $postPlatform, $media): array
private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array
{
$creatorInfo = $this->queryCreatorInfo();
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/video/init/", [
'post_info' => [
'title' => $postPlatform->content ?? '',
'title' => $content ?? '',
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
'disable_duet' => false,
'disable_comment' => false,
@ -137,7 +141,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
];
}
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array
{
$photoUrls = $mediaCollection
->filter(fn ($m) => $m->isImage())
@ -154,7 +158,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/content/init/", [
'post_info' => [
'title' => $postPlatform->content ?? '',
'title' => $content ?? '',
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
'disable_comment' => false,
],

View file

@ -26,6 +26,8 @@ class XPublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
// Refresh token if expired or expiring soon

View file

@ -26,6 +26,10 @@ class YouTubePublisher
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
if ($account->is_token_expired || $account->is_token_expiring_soon) {
@ -45,7 +49,7 @@ public function publish(PostPlatform $postPlatform): array
throw new \Exception('YouTube Shorts only supports video content.');
}
return $this->publishShort($postPlatform, $firstMedia, $account);
return $this->publishShort($postPlatform, $firstMedia, $account, $content);
}
private function createGoogleClient(SocialAccount $account): GoogleClient
@ -73,14 +77,14 @@ private function createGoogleClient(SocialAccount $account): GoogleClient
return $client;
}
private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account): array
private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account, ?string $content): array
{
if (empty($postPlatform->content)) {
if (empty($content)) {
throw new \Exception('YouTube Shorts require a title. Please add text to your post.');
}
$title = $this->buildTitle($postPlatform->content);
$description = $postPlatform->content;
$title = $this->buildTitle($content);
$description = $content;
$tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_');
$handle = null;

View file

@ -41,6 +41,7 @@ public function linkedin(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::LinkedIn,
'scopes' => Platform::LinkedIn->requiredPublishScopes(),
]);
}
@ -48,6 +49,7 @@ public function linkedinPage(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::LinkedInPage,
'scopes' => Platform::LinkedInPage->requiredPublishScopes(),
]);
}
@ -55,6 +57,7 @@ public function x(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::X,
'scopes' => Platform::X->requiredPublishScopes(),
]);
}
@ -62,6 +65,7 @@ public function tiktok(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::TikTok,
'scopes' => Platform::TikTok->requiredPublishScopes(),
]);
}
@ -69,6 +73,7 @@ public function youtube(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::YouTube,
'scopes' => Platform::YouTube->requiredPublishScopes(),
]);
}
@ -76,6 +81,7 @@ public function facebook(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Facebook,
'scopes' => Platform::Facebook->requiredPublishScopes(),
]);
}
@ -83,6 +89,7 @@ public function instagram(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Instagram,
'scopes' => Platform::Instagram->requiredPublishScopes(),
]);
}
@ -90,6 +97,7 @@ public function threads(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Threads,
'scopes' => Platform::Threads->requiredPublishScopes(),
]);
}
@ -97,6 +105,7 @@ public function pinterest(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Pinterest,
'scopes' => Platform::Pinterest->requiredPublishScopes(),
]);
}
@ -104,6 +113,7 @@ public function bluesky(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Bluesky,
'scopes' => Platform::Bluesky->requiredPublishScopes(),
'token_expires_at' => now()->addHours(2),
'meta' => [
'service' => 'https://bsky.social',
@ -117,6 +127,7 @@ public function mastodon(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Mastodon,
'scopes' => Platform::Mastodon->requiredPublishScopes(),
'token_expires_at' => null, // Mastodon tokens don't expire
'meta' => [
'instance' => 'https://mastodon.social',

View file

@ -4,9 +4,11 @@
use App\Console\Commands\CheckSocialConnections;
use App\Console\Commands\ProcessScheduledPosts;
use App\Console\Commands\RecoverStuckPosts;
use App\Console\Commands\RefreshExpiringTokens;
use Illuminate\Support\Facades\Schedule;
Schedule::command(ProcessScheduledPosts::class)->everyMinute();
Schedule::command(CheckSocialConnections::class)->daily();
Schedule::command(RefreshExpiringTokens::class)->hourly();
Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes();

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
});
test('it recovers posts stuck in publishing for over 1 hour', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$platform->refresh();
$post->refresh();
expect($platform->status)->toBe(PlatformStatus::Failed);
expect($platform->error_message)->toBe('Publishing timed out. Please try again.');
expect($post->status)->toBe(PostStatus::Failed);
});
test('it does not touch posts publishing for less than 1 hour', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subMinutes(30),
]);
$platform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subMinutes(30),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$platform->refresh();
expect($platform->status)->toBe(PlatformStatus::Publishing);
});
test('it marks post as partially published when some platforms succeeded', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
'updated_at' => now()->subHours(2),
]);
// One succeeded
PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $this->socialAccount->id,
'status' => PlatformStatus::Published,
'enabled' => true,
]);
// One stuck
$stuckPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Instagram,
])->id,
'status' => PlatformStatus::Publishing,
'enabled' => true,
'updated_at' => now()->subHours(2),
]);
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
$stuckPlatform->refresh();
$post->refresh();
expect($stuckPlatform->status)->toBe(PlatformStatus::Failed);
expect($post->status)->toBe(PostStatus::PartiallyPublished);
});

View file

@ -385,6 +385,21 @@
expect($this->postPlatform->error_context['content_length'])->toBe(11);
});
test('publish to social platform fails when scopes are missing', function () {
Event::fake();
$this->socialAccount->update(['scopes' => ['user.info.basic']]); // missing w_member_social
$this->postPlatform->refresh();
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toContain('Missing permissions');
expect($this->postPlatform->error_context['category'])->toBe('permission');
expect($this->postPlatform->error_context['missing_scopes'])->toContain('w_member_social');
});
test('publish to social platform saves error context on token expired', function () {
Event::fake();
Mail::fake();

View file

@ -2,6 +2,13 @@
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -15,6 +22,11 @@ public function makeRequest(string $url): Response
{
return $this->socialHttp()->get($url);
}
public function callValidateContentLength(PostPlatform $postPlatform): void
{
$this->validateContentLength($postPlatform);
}
};
});
@ -66,3 +78,38 @@ public function makeRequest(string $url): Response
expect($response->status())->toBe(200);
Http::assertSentCount(1);
});
test('validateContentLength passes when content is within limit', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $socialAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'content' => str_repeat('a', 100), // well within LinkedIn's 3000 limit
]);
expect(fn () => $this->client->callValidateContentLength($postPlatform))->not->toThrow(Exception::class);
});
test('validateContentLength throws when content exceeds limit', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $post->id,
'social_account_id' => $socialAccount->id,
'platform' => Platform::LinkedIn,
'content_type' => ContentType::LinkedInPost,
'content' => str_repeat('a', 4000), // exceeds LinkedIn's 3000 limit
]);
expect(fn () => $this->client->callValidateContentLength($postPlatform))
->toThrow(Exception::class, '3000');
});

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Services\Social\ContentSanitizer;
test('it strips html tags for plain text platforms', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>Hello <strong>world</strong></p>', Platform::Instagram);
expect($result)->toBe('Hello world');
});
test('it converts bold to unicode for linkedin', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>Hello <strong>world</strong></p>', Platform::LinkedIn);
expect($result)->toContain('𝘄𝗼𝗿𝗹𝗱');
});
test('it converts p tags to newlines', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<p>First paragraph</p><p>Second paragraph</p>', Platform::X);
expect($result)->toBe("First paragraph\nSecond paragraph");
});
test('it converts br to newlines', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('Line one<br>Line two', Platform::Facebook);
expect($result)->toBe("Line one\nLine two");
});
test('it decodes html entities', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('Tom &amp; Jerry &lt;3', Platform::X);
expect($result)->toBe('Tom & Jerry <3');
});
test('it returns plain text unchanged', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('Just plain text', Platform::Instagram);
expect($result)->toBe('Just plain text');
});
test('it handles null-safe empty content', function () {
// The publisher handles null check, sanitizer should handle empty string
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('', Platform::X);
expect($result)->toBe('');
});
test('it converts list items to dashes', function () {
$sanitizer = new ContentSanitizer;
$result = $sanitizer->sanitize('<ul><li>Item one</li><li>Item two</li></ul>', Platform::Facebook);
expect($result)->toContain('- Item one');
expect($result)->toContain('- Item two');
});