diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php index 79d0ad84b..d3f25b64e 100644 --- a/app/Services/Social/FacebookAnalytics.php +++ b/app/Services/Social/FacebookAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\PostPlatform\ContentType; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -38,6 +39,9 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul }); } + /** + * @return array|array{unsupported: true, reason: string} + */ public function fetchPostMetrics(PostPlatform $postPlatform): array { $account = $postPlatform->socialAccount; @@ -46,31 +50,97 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } + [$edge, $metrics] = $this->postMetricsFor($postPlatform); + $response = $this->socialHttp() - ->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/insights", [ - 'metric' => 'post_impressions,post_impressions_unique,post_reactions_like_total,post_clicks', + ->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/{$edge}", [ + 'metric' => implode(',', array_keys($metrics)), + 'period' => 'lifetime', 'access_token' => $account->access_token, ]); if ($response->failed()) { Log::warning('Facebook post metrics fetch failed', [ + 'content_type' => $postPlatform->content_type?->value, 'body' => $this->redactResponseBody($response->body()), ]); return ['unsupported' => true, 'reason' => 'api_error']; } - $insights = data_get($response->json(), 'data', []); - - return collect($insights) - ->map(fn (array $item) => [ - 'label' => ucfirst(str_replace('_', ' ', data_get($item, 'name', ''))), - 'value' => (int) data_get($item, 'values.0.value', 0), + return collect(data_get($response->json(), 'data', [])) + ->filter(fn (array $item): bool => isset($metrics[data_get($item, 'name')])) + ->map(fn (array $item): array => [ + 'label' => __($metrics[data_get($item, 'name')]), + 'value' => $this->metricValue(data_get($item, 'values.0.value')), ]) ->values() ->all(); } + /** + * The stored id tells us which Graph node we are looking at, and each node + * answers a different insights call: + * + * - A Story is a story post; only the `story` metric family is valid. + * - A feed post (text, photo, carousel) is stored as `{page_id}_{post_id}` + * and has the `/insights` edge with `post_*` metrics. + * - A bare id is a video node: Reels and timeline videos both come back from + * Meta as the video's own id. The video node has no `/insights` edge at + * all; its numbers live on `/video_insights`, and Meta reports them with + * the Reels metric names for any short video. + * + * Asking the wrong node is a `#100` rejection, not an empty result. The + * `post_impressions*` family is deprecated above Graph API v25, so feed + * posts read the `media_view` replacements. Every call pins + * `period=lifetime`: without it Meta returns some post metrics twice, once + * per period, and the card would show the same label two times. + * + * @return array{0: string, 1: array} + */ + private function postMetricsFor(PostPlatform $postPlatform): array + { + if ($postPlatform->content_type === ContentType::FacebookStory) { + return ['insights', [ + 'page_story_impressions_by_story_id' => 'analytics.metrics.impressions', + 'page_story_impressions_by_story_id_unique' => 'analytics.metrics.reach', + 'story_interaction' => 'analytics.metrics.interactions', + 'pages_fb_story_thread_lightweight_reactions' => 'analytics.metrics.reactions', + 'pages_fb_story_replies' => 'analytics.metrics.replies', + 'pages_fb_story_shares' => 'analytics.metrics.shares', + ]]; + } + + if (str_contains((string) $postPlatform->platform_post_id, '_')) { + return ['insights', [ + 'post_media_view' => 'analytics.metrics.impressions', + 'post_total_media_view_unique' => 'analytics.metrics.reach', + 'post_reactions_like_total' => 'analytics.metrics.likes', + 'post_clicks' => 'analytics.metrics.clicks', + ]]; + } + + return ['video_insights', [ + 'fb_reels_total_plays' => 'analytics.metrics.video_views', + 'post_video_likes_by_reaction_type' => 'analytics.metrics.reactions', + 'post_video_social_actions' => 'analytics.metrics.interactions', + ]]; + } + + /** + * Most metrics are a plain count; the `*_by_reaction_type` and + * `social_actions` metrics return one count per type and are reported as + * their sum. An empty breakdown arrives as `[]`. + */ + private function metricValue(mixed $value): int + { + if (is_array($value)) { + return (int) collect($value)->sum(); + } + + return (int) $value; + } + private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { $this->accessToken = $account->access_token; diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 64a7e1a27..b9faf1839 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -4,24 +4,36 @@ namespace App\Services\Social; +use App\Dto\MediaItem; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\FacebookPublishException; 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 Closure; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Sleep; class FacebookPublisher { use CropsImageForAspectRatio; use HasSocialHttpClient; + private const int VIDEO_UPLOAD_POLL_SECONDS = 5; + + private const int VIDEO_UPLOAD_MAX_POLLS = 60; + + private const int UNREACHABLE_RETRY_DELAY_SECONDS = 60; + private string $baseUrl; public function __construct() @@ -30,32 +42,23 @@ public function __construct() } /** - * Graph API expects application/x-www-form-urlencoded (or multipart), not JSON. - * Sending JSON makes `message` work but silently drops `attached_media[*]` on /feed. + * @return array{id: mixed, url: string} */ - private function facebookHttp(): PendingRequest - { - return $this->socialHttp()->asForm(); - } - public function publish(PostPlatform $postPlatform): array { $this->validateContentLength($postPlatform); - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; - $account = $postPlatform->socialAccount; $pageId = $account->platform_user_id; $accessToken = $account->access_token; - + $content = $this->sanitizedContent($postPlatform); $media = $postPlatform->post->mediaItems; $contentType = $postPlatform->content_type; - $aspectRatio = data_get($postPlatform->meta, 'aspect_ratio'); return match ($contentType) { - ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()), - ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()), - ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $aspectRatio), + ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $this->requireVideo($media->first(), 'Reels')), + ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $this->requireVideo($media->first(), 'Stories')), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, data_get($postPlatform->meta, 'aspect_ratio')), default => throw new FacebookPublishException( userMessage: "Unsupported Facebook content type: {$contentType?->value}", category: ErrorCategory::MediaFormat, @@ -63,362 +66,468 @@ public function publish(PostPlatform $postPlatform): array }; } - private function publishPost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array + /** + * @param Collection $media + * @return array{id: mixed, url: string} + */ + private function publishPost(string $pageId, string $accessToken, ?string $content, Collection $media, ?string $aspectRatio): array { - // Text only post if ($media->isEmpty()) { - if ($content === null || $content === '') { - throw new FacebookPublishException( - userMessage: 'Facebook text posts require content. Please add text to your post.', - category: ErrorCategory::MediaFormat, - ); - } - return $this->publishTextPost($pageId, $accessToken, $content); } $firstMedia = $media->first(); - $isVideo = $firstMedia->isVideo(); - $isImage = $firstMedia->isImage(); - - if ($isVideo) { - return $this->publishVideoPost($pageId, $accessToken, $content, $firstMedia); - } - if ($isImage) { - // Single or multiple images - if ($media->count() === 1) { - return $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia, $aspectRatio); - } + return match (true) { + $firstMedia->isVideo() => $this->publishVideoPost($pageId, $accessToken, $content, $firstMedia), + $firstMedia->isImage() && $media->count() === 1 => $this->publishSingleImagePost($pageId, $accessToken, $content, $firstMedia, $aspectRatio), + $firstMedia->isImage() => $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio), + default => throw new FacebookPublishException( + userMessage: 'Unsupported media type for Facebook', + category: ErrorCategory::MediaFormat, + ), + }; + } - return $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio); + /** + * @return array{id: mixed, url: string} + */ + private function publishTextPost(string $pageId, string $accessToken, ?string $content): array + { + if (! filled($content)) { + throw new FacebookPublishException( + userMessage: 'Facebook text posts require content. Please add text to your post.', + category: ErrorCategory::MediaFormat, + ); } - throw new FacebookPublishException( - userMessage: 'Unsupported media type for Facebook', - category: ErrorCategory::MediaFormat, - ); + $response = $this->postToGraph("{$pageId}/feed", [ + 'message' => $content, + 'access_token' => $accessToken, + ], 'text post'); + + return $this->feedPostResult(data_get($response->json(), 'id')); } - private function publishTextPost(string $pageId, string $accessToken, string $content): array + /** + * @return array{id: mixed, url: string} + */ + private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, MediaItem $media, ?string $aspectRatio): array { - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", [ - 'message' => $content, + $response = $this->postToGraph("{$pageId}/photos", [ + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'access_token' => $accessToken, - ]); - - if ($response->failed()) { - Log::error('Facebook text post failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); - } + ...$this->optionalField('message', $content), + ...$this->altText($media), + ], 'single image post'); $data = $response->json(); - $postId = data_get($data, 'id'); - return [ - 'id' => $postId, - 'url' => "https://www.facebook.com/{$postId}", - ]; + return $this->feedPostResult(data_get($data, 'post_id') ?? data_get($data, 'id')); } - private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array + /** + * Every image is uploaded unpublished and then attached to one feed post. An + * image Facebook rejects is skipped so the rest still goes out; the post only + * fails when none of them made it. + * + * @param Collection $media + * @return array{id: mixed, url: string} + */ + private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, Collection $media, ?string $aspectRatio): array { - $payload = [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), - 'access_token' => $accessToken, - ]; + $photoIds = $media + ->filter(fn (MediaItem $item): bool => $item->isImage()) + ->map(fn (MediaItem $item): ?string => $this->uploadUnpublishedPhoto($pageId, $accessToken, $item, $aspectRatio)) + ->filter() + ->values(); - if ($content !== null && $content !== '') { - $payload['message'] = $content; + if ($photoIds->isEmpty()) { + throw new FacebookPublishException( + userMessage: 'Failed to upload any images to Facebook', + category: ErrorCategory::ServerError, + ); } - $alt = $media->altTextFor(Platform::Facebook); + $response = $this->postToGraph("{$pageId}/feed", [ + 'access_token' => $accessToken, + ...$this->optionalField('message', $content), + ...$photoIds + ->mapWithKeys(fn (string $photoId, int $index): array => ["attached_media[{$index}]" => json_encode(['media_fbid' => $photoId])]) + ->all(), + ], 'multi-image post'); - if ($alt !== null) { - $payload['alt_text_custom'] = $alt; - } + return $this->feedPostResult(data_get($response->json(), 'id')); + } + + private function uploadUnpublishedPhoto(string $pageId, string $accessToken, MediaItem $media, ?string $aspectRatio): ?string + { + $response = $this->reachOrRetry( + fn (): Response => $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), + 'published' => 'false', + 'access_token' => $accessToken, + ...$this->altText($media), + ]), + 'image upload', + ); - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload); + $photoId = data_get($response->json(), 'id'); - if ($response->failed()) { - Log::error('Facebook single image post failed', [ + if ($response->failed() || ! is_string($photoId) || $photoId === '') { + Log::error('Facebook image upload failed', [ 'status' => $response->status(), 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response); + + return null; } - $data = $response->json(); - $postId = data_get($data, 'post_id', data_get($data, 'id')); + return $photoId; + } + + /** + * @return array{id: mixed, url: string} + */ + private function publishVideoPost(string $pageId, string $accessToken, ?string $content, MediaItem $media): array + { + $response = $this->postToGraph("{$pageId}/videos", [ + 'file_url' => $media->url, + 'access_token' => $accessToken, + ...$this->optionalField('description', $content), + ], 'video post'); + + $videoId = data_get($response->json(), 'id'); return [ - 'id' => $postId, - 'url' => "https://www.facebook.com/{$postId}", + 'id' => $videoId, + 'url' => "https://www.facebook.com/{$pageId}/videos/{$videoId}", ]; } - private function publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection, ?string $aspectRatio): array + /** + * @return array{id: mixed, url: string} + */ + private function publishReel(string $pageId, string $accessToken, ?string $content, MediaItem $media): array { - // Upload each image as unpublished - $attachedMedia = []; + $videoId = $this->uploadVideo($pageId, $accessToken, 'video_reels', $media); - foreach ($mediaCollection as $media) { - if (! $media->isImage()) { - continue; - } - - $uploadPayload = [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), - 'published' => 'false', - 'access_token' => $accessToken, - ]; + $response = $this->postToGraph("{$pageId}/video_reels", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'video_state' => 'PUBLISHED', + 'access_token' => $accessToken, + ...$this->optionalField('description', $content), + ], 'reel finish'); - $alt = $media->altTextFor(Platform::Facebook); + $reelId = data_get($response->json(), 'id') ?? $videoId; - if ($alt !== null) { - $uploadPayload['alt_text_custom'] = $alt; - } + return [ + 'id' => $reelId, + 'url' => "https://www.facebook.com/reel/{$reelId}", + ]; + } - $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload); + /** + * @return array{id: mixed, url: string} + */ + private function publishStory(string $pageId, string $accessToken, MediaItem $media): array + { + $videoId = $this->uploadVideo($pageId, $accessToken, 'video_stories', $media); - if ($uploadResponse->failed()) { - Log::error('Facebook image upload failed', [ - 'body' => $this->redactResponseBody($uploadResponse->body()), - ]); + $response = $this->postToGraph("{$pageId}/video_stories", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'access_token' => $accessToken, + ], 'story finish'); - continue; - } + $storyId = data_get($response->json(), 'post_id') ?? $videoId; - $uploadData = $uploadResponse->json(); - $attachedMedia[] = ['media_fbid' => $uploadData['id']]; - } + return [ + 'id' => $storyId, + 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", + ]; + } - if (empty($attachedMedia)) { - throw new FacebookPublishException( - userMessage: 'Failed to upload any images to Facebook', - category: ErrorCategory::ServerError, - ); - } + /** + * Meta's resumable video flow, shared by Reels and Stories. `start` on the + * Graph edge opens a session and returns a rupload URL; we hand that URL our + * CDN link and Meta fetches the file itself (the "hosted file" upload), so + * no video bytes pass through the worker. Because that fetch is + * asynchronous the caller must not `finish` until the status poll reports + * the upload complete: finishing an empty session is error 6000. + * + * Returns the `video_id` the caller passes to `finish`. + */ + private function uploadVideo(string $pageId, string $accessToken, string $edge, MediaItem $media): string + { + [$videoId, $uploadUrl] = $this->startVideoUpload($pageId, $accessToken, $edge); - // Create the post with attached media - $postData = [ - 'access_token' => $accessToken, - ]; + $this->uploadVideoFromUrl($uploadUrl, $accessToken, $media); + $this->waitForVideoUpload($videoId, $accessToken); - if ($content !== null && $content !== '') { - $postData['message'] = $content; - } + return $videoId; + } - foreach ($attachedMedia as $index => $media) { - $postData["attached_media[{$index}]"] = json_encode($media); - } + /** + * @return array{0: string, 1: string} + */ + private function startVideoUpload(string $pageId, string $accessToken, string $edge): array + { + $response = $this->postToGraph("{$pageId}/{$edge}", [ + 'upload_phase' => 'start', + 'access_token' => $accessToken, + ], "{$edge} start"); - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData); + $data = $response->json(); + $videoId = data_get($data, 'video_id'); + $uploadUrl = data_get($data, 'upload_url'); - if ($response->failed()) { - Log::error('Facebook multi-image post failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); + if (! filled($videoId) || ! is_string($uploadUrl) || ! filled($uploadUrl)) { + throw new FacebookPublishException( + userMessage: 'Facebook did not start the video upload. Please try again.', + category: ErrorCategory::ServerError, + rawResponse: $response->body(), + ); } - $data = $response->json(); - $postId = data_get($data, 'id'); + $this->assertRuploadUrl($uploadUrl); - return [ - 'id' => $postId, - 'url' => "https://www.facebook.com/{$postId}", - ]; + return [(string) $videoId, $uploadUrl]; } - private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array + /** + * The `upload_url` must point at Meta's rupload host. Anything else would + * send the Page token and our media URL to a third party. + */ + private function assertRuploadUrl(string $uploadUrl): void { - $payload = [ - 'file_url' => $media->url, - 'access_token' => $accessToken, - ]; + $parts = parse_url($uploadUrl); + $allowedHost = config('trypost.platforms.facebook.rupload_host'); - if ($content !== null && $content !== '') { - $payload['description'] = $content; + if (data_get($parts, 'scheme') !== 'https' || data_get($parts, 'host') !== $allowedHost) { + throw new FacebookPublishException( + userMessage: 'Facebook returned an invalid upload URL.', + category: ErrorCategory::ServerError, + rawResponse: $uploadUrl, + ); } + } - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload); + /** + * The request is the two headers and no body; Meta fetches `file_url`. + */ + private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, MediaItem $media): void + { + $response = $this->reachOrRetry( + fn (): Response => $this->socialHttp() + ->withHeaders([ + 'Authorization' => "OAuth {$accessToken}", + 'file_url' => $media->url, + ]) + ->send('POST', $uploadUrl), + 'video upload', + ); if ($response->failed()) { - Log::error('Facebook video post failed', [ - 'status' => $response->status(), + Log::error('Facebook video upload failed', [ 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); } - $data = $response->json(); - $videoId = data_get($data, 'id'); - - return [ - 'id' => $videoId, - 'url' => "https://www.facebook.com/{$pageId}/videos/{$videoId}", - ]; + if (data_get($response->json(), 'success') !== true) { + throw new FacebookPublishException( + userMessage: 'Facebook did not accept the video. Please try again.', + category: ErrorCategory::ServerError, + rawResponse: $response->body(), + ); + } } - private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array + private function waitForVideoUpload(string $videoId, string $accessToken): void { - // Phase 1 (start) — graph endpoint returns video_id + upload_url. - $startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ - 'upload_phase' => 'start', - 'access_token' => $accessToken, - ]); - - if ($startResponse->failed()) { - $this->handleApiError($startResponse); - } - - $startData = $startResponse->json(); - $videoId = data_get($startData, 'video_id'); - $uploadUrl = data_get($startData, 'upload_url'); + for ($attempt = 1; $attempt <= self::VIDEO_UPLOAD_MAX_POLLS; $attempt++) { + if ($attempt > 1) { + Sleep::for(self::VIDEO_UPLOAD_POLL_SECONDS)->seconds(); + } - if (! $videoId || ! $uploadUrl) { - throw new FacebookPublishException( - userMessage: 'Facebook did not return upload_url for reel start.', - category: ErrorCategory::ServerError, - platformErrorCode: null, - rawResponse: $startResponse->body(), + $response = $this->reachOrRetry( + fn (): Response => $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ + 'fields' => 'status', + 'access_token' => $accessToken, + ]), + 'video status', ); - } - // Phase 2 (transfer, local-file flow) — download our hosted - // media then POST raw bytes to upload_url with the Offset and - // file_size headers Facebook requires (the docs describe a - // hosted-file shortcut with `file_url` in the body, but rupload - // rejects it with "Header Offset not convertable to unsigned - // long" — the headers are required either way). - $tempFile = tempnam(sys_get_temp_dir(), 'fb_reel_'); + if ($response->failed()) { + if (! GraphError::isTransientFailure($response)) { + $this->handleApiError($response); + } - try { - $download = Http::withOptions(['sink' => $tempFile]) - ->timeout(600) - ->get($media->url); + Log::warning('Facebook video status check failed transiently', [ + 'video_id' => $videoId, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); - if ($download->failed()) { - throw new FacebookPublishException( - userMessage: 'Could not download media for Facebook reel.', - category: ErrorCategory::ServerError, - platformErrorCode: (string) $download->status(), - rawResponse: null, - ); + continue; } - $fileSize = filesize($tempFile); - $stream = fopen($tempFile, 'rb'); + $status = data_get($response->json(), 'status', []); + $failure = $this->videoUploadFailure($status); - try { - $uploadResponse = Http::withHeaders([ - 'Authorization' => "OAuth {$accessToken}", - 'Offset' => '0', - 'file_size' => (string) $fileSize, - ]) - ->timeout(600) - ->withBody($stream, $media->mime_type ?? 'video/mp4') - ->post($uploadUrl); - } finally { - if (is_resource($stream)) { - fclose($stream); - } + if ($failure !== null) { + throw new FacebookPublishException( + userMessage: $failure, + category: ErrorCategory::MediaFormat, + rawResponse: $response->body(), + ); } - if ($uploadResponse->failed()) { - $this->handleApiError($uploadResponse); - } - } finally { - if (! unlink($tempFile)) { - Log::warning('Facebook reel temp file cleanup failed', ['path' => $tempFile]); + if ($this->videoUploadComplete($status)) { + return; } } - // Phase 3 (finish) — publish the reel. - $finishPayload = [ - 'upload_phase' => 'finish', - 'video_id' => $videoId, - 'video_state' => 'PUBLISHED', - 'access_token' => $accessToken, - ]; - - if ($content !== null && $content !== '') { - $finishPayload['description'] = $content; - } + throw new FacebookPublishException( + userMessage: 'Facebook took too long to fetch the video. Please try again.', + category: ErrorCategory::ServerError, + ); + } - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload); + /** + * The user-facing reason the upload failed, or null while it is still healthy. + * Meta reports a processing failure as an `error` object on the phase, not + * always as `status: error`, so the message is checked first. + * + * @param array $status + */ + private function videoUploadFailure(array $status): ?string + { + $detail = data_get($status, 'processing_phase.error.message') + ?? data_get($status, 'uploading_phase.error.message'); - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); + if (is_string($detail) && $detail !== '') { + return $detail; } - $finishData = $finishResponse->json(); - $reelId = $finishData['id'] ?? $videoId; + $failed = in_array(data_get($status, 'video_status'), ['error', 'expired'], true) + || data_get($status, 'uploading_phase.status') === 'error' + || data_get($status, 'processing_phase.status') === 'error'; - return [ - 'id' => $reelId, - 'url' => "https://www.facebook.com/reel/{$reelId}", - ]; + return $failed ? 'Facebook could not process the video. Please try another file.' : null; + } + + /** + * @param array $status + */ + private function videoUploadComplete(array $status): bool + { + return data_get($status, 'uploading_phase.status') === 'complete' + || in_array(data_get($status, 'video_status'), ['ready', 'upload_complete'], true); } - private function publishStory(string $pageId, string $accessToken, $media): array + private function requireVideo(?MediaItem $media, string $format): MediaItem { - if (! $media->isVideo()) { + if ($media === null || ! $media->isVideo()) { throw new FacebookPublishException( - userMessage: 'Facebook Stories require a video file.', + userMessage: "Facebook {$format} require a video file.", category: ErrorCategory::MediaFormat, ); } - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'start', - 'access_token' => $accessToken, - ]); + return $media; + } - if ($response->failed()) { - $this->handleApiError($response); - } + private function sanitizedContent(PostPlatform $postPlatform): ?string + { + $content = $postPlatform->post->content; - $videoId = $response->json()['video_id'] ?? null; + return filled($content) + ? app(ContentSanitizer::class)->sanitize($content, $postPlatform->platform) + : null; + } - if (! $videoId) { - throw new FacebookPublishException( - userMessage: 'Facebook did not accept the story video. Please try again.', - category: ErrorCategory::ServerError, - ); - } + /** + * Graph API expects application/x-www-form-urlencoded (or multipart), not JSON. + * Sending JSON makes `message` work but silently drops `attached_media[*]` on /feed. + */ + private function facebookHttp(): PendingRequest + { + return $this->socialHttp()->asForm(); + } - $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ - 'upload_phase' => 'transfer', - 'video_file_chunk' => $media->url, - 'access_token' => $accessToken, - ]); + /** + * POST a form payload to a Graph edge and turn any failure into the typed + * exception, logging the redacted body under `$label` first. + * + * @param array $payload + */ + private function postToGraph(string $path, array $payload, string $label): Response + { + $response = $this->reachOrRetry( + fn (): Response => $this->facebookHttp()->post("{$this->baseUrl}/{$path}", $payload), + $label, + ); - if ($transferResponse->failed()) { - Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); - $this->handleApiError($transferResponse); + if ($response->failed()) { + Log::error("Facebook {$label} failed", [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); } - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'finish', - 'video_id' => $videoId, - 'access_token' => $accessToken, - ]); + return $response; + } - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); + /** + * A connection that never completes (DNS, TCP or TLS timeout) says nothing + * about the post or the token, so it is rescheduled instead of reported as + * an unexpected failure. Facebook's Graph and rupload hosts drop connections + * often enough for this to matter. cURL quotes the full URL in its message, + * query string included, so the message is redacted before it reaches + * error_context or the logs. + * + * @param Closure(): Response $request + */ + private function reachOrRetry(Closure $request, string $label): Response + { + try { + return $request(); + } catch (ConnectionException $exception) { + throw new PlatformUnavailableException( + message: "Facebook {$label} unreachable: ".$this->redactResponseBody($exception->getMessage()), + retryDelaySeconds: self::UNREACHABLE_RETRY_DELAY_SECONDS, + ); } + } - $storyId = $finishResponse->json()['post_id'] ?? $videoId; + /** + * @return array + */ + private function optionalField(string $key, ?string $value): array + { + return filled($value) ? [$key => $value] : []; + } + /** + * @return array + */ + private function altText(MediaItem $media): array + { + return $this->optionalField('alt_text_custom', $media->altTextFor(Platform::Facebook)); + } + + /** + * @return array{id: mixed, url: string} + */ + private function feedPostResult(mixed $postId): array + { return [ - 'id' => $storyId, - 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", + 'id' => $postId, + 'url' => "https://www.facebook.com/{$postId}", ]; } diff --git a/config/trypost.php b/config/trypost.php index aed84cd6b..a03a69ff5 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -201,6 +201,7 @@ 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), 'graph_api' => env('FACEBOOK_GRAPH_API', 'https://graph.facebook.com/v25.0'), + 'rupload_host' => env('FACEBOOK_RUPLOAD_HOST', 'rupload.facebook.com'), ], 'instagram' => [ 'enabled' => env('INSTAGRAM_ENABLED', true), diff --git a/lang/ar/analytics.php b/lang/ar/analytics.php index 025526c5f..33a8e9250 100644 --- a/lang/ar/analytics.php +++ b/lang/ar/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'وصول المنشورات', 'quotes' => 'الاقتباسات', 'reach' => 'الوصول', + 'reactions' => 'التفاعلات', 'reblogs' => 'إعادات التدوين', 'recent_comments' => 'أحدث التعليقات', 'recent_likes' => 'أحدث الإعجابات', diff --git a/lang/de/analytics.php b/lang/de/analytics.php index 57e97d26f..ea4826ece 100644 --- a/lang/de/analytics.php +++ b/lang/de/analytics.php @@ -37,6 +37,7 @@ 'posts_reach' => 'Beitrags-Reichweite', 'quotes' => 'Zitate', 'reach' => 'Reichweite', + 'reactions' => 'Reaktionen', 'reblogs' => 'Reblogs', 'recent_comments' => 'Neueste Kommentare', 'recent_likes' => 'Neueste Likes', diff --git a/lang/el/analytics.php b/lang/el/analytics.php index 779ecc226..1ae39d699 100644 --- a/lang/el/analytics.php +++ b/lang/el/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Απήχηση δημοσιεύσεων', 'quotes' => 'Παραθέσεις', 'reach' => 'Απήχηση', + 'reactions' => 'Αντιδράσεις', 'reblogs' => 'Αναδημοσιεύσεις', 'recent_comments' => 'Πρόσφατα σχόλια', 'recent_likes' => 'Πρόσφατα μου αρέσει', diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 99c8190c2..0d912b9e3 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Posts Reach', 'quotes' => 'Quotes', 'reach' => 'Reach', + 'reactions' => 'Reactions', 'reblogs' => 'Reblogs', 'recent_comments' => 'Recent Comments', 'recent_likes' => 'Recent Likes', diff --git a/lang/es/analytics.php b/lang/es/analytics.php index da4d2a406..e6075494f 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Alcance de Publicaciones', 'quotes' => 'Citas', 'reach' => 'Alcance', + 'reactions' => 'Reacciones', 'reblogs' => 'Reblogs', 'recent_comments' => 'Comentarios Recientes', 'recent_likes' => 'Me Gusta Recientes', diff --git a/lang/fr/analytics.php b/lang/fr/analytics.php index c26d20b15..bc9fbb735 100644 --- a/lang/fr/analytics.php +++ b/lang/fr/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Portée des publications', 'quotes' => 'Citations', 'reach' => 'Portée', + 'reactions' => 'Réactions', 'reblogs' => 'Repartages', 'recent_comments' => 'Commentaires récents', 'recent_likes' => 'J\'aime récents', diff --git a/lang/it/analytics.php b/lang/it/analytics.php index 2bce4a934..2ea39ede4 100644 --- a/lang/it/analytics.php +++ b/lang/it/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Copertura dei post', 'quotes' => 'Citazioni', 'reach' => 'Copertura', + 'reactions' => 'Reazioni', 'reblogs' => 'Reblog', 'recent_comments' => 'Commenti recenti', 'recent_likes' => 'Mi piace recenti', diff --git a/lang/ja/analytics.php b/lang/ja/analytics.php index 22f015157..7007223de 100644 --- a/lang/ja/analytics.php +++ b/lang/ja/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => '投稿リーチ', 'quotes' => '引用', 'reach' => 'リーチ', + 'reactions' => 'リアクション', 'reblogs' => 'リブログ', 'recent_comments' => '最近のコメント', 'recent_likes' => '最近のいいね', diff --git a/lang/ko/analytics.php b/lang/ko/analytics.php index 946ba066c..3093663e4 100644 --- a/lang/ko/analytics.php +++ b/lang/ko/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => '게시물 도달수', 'quotes' => '인용', 'reach' => '도달수', + 'reactions' => '반응', 'reblogs' => '리블로그', 'recent_comments' => '최근 댓글', 'recent_likes' => '최근 좋아요', diff --git a/lang/nl/analytics.php b/lang/nl/analytics.php index e62378ec1..1b5748a36 100644 --- a/lang/nl/analytics.php +++ b/lang/nl/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Postbereik', 'quotes' => 'Citaten', 'reach' => 'Bereik', + 'reactions' => 'Reacties', 'reblogs' => 'Reblogs', 'recent_comments' => 'Recente reacties', 'recent_likes' => 'Recente likes', diff --git a/lang/pl/analytics.php b/lang/pl/analytics.php index 19def1bd9..e9a4b683b 100644 --- a/lang/pl/analytics.php +++ b/lang/pl/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Zasięg postów', 'quotes' => 'Cytaty', 'reach' => 'Zasięg', + 'reactions' => 'Reakcje', 'reblogs' => 'Podania dalej', 'recent_comments' => 'Ostatnie komentarze', 'recent_likes' => 'Ostatnie polubienia', diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index bad4c550d..6e7eee85e 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Alcance dos Posts', 'quotes' => 'Citações', 'reach' => 'Alcance', + 'reactions' => 'Reações', 'reblogs' => 'Reblogs', 'recent_comments' => 'Comentários Recentes', 'recent_likes' => 'Curtidas Recentes', diff --git a/lang/ru/analytics.php b/lang/ru/analytics.php index a0c767540..f4bf2796f 100644 --- a/lang/ru/analytics.php +++ b/lang/ru/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Охват постов', 'quotes' => 'Цитаты', 'reach' => 'Охват', + 'reactions' => 'Реакции', 'reblogs' => 'Реблоги', 'recent_comments' => 'Недавние комментарии', 'recent_likes' => 'Недавние лайки', diff --git a/lang/tr/analytics.php b/lang/tr/analytics.php index daafd3b3b..dc3a0f6e3 100644 --- a/lang/tr/analytics.php +++ b/lang/tr/analytics.php @@ -37,6 +37,7 @@ 'posts_reach' => 'Gönderi Erişimi', 'quotes' => 'Alıntılar', 'reach' => 'Erişim', + 'reactions' => 'Tepkiler', 'reblogs' => 'Yeniden Bloglamalar', 'recent_comments' => 'Son Yorumlar', 'recent_likes' => 'Son Beğeniler', diff --git a/lang/uk/analytics.php b/lang/uk/analytics.php index 1a4891b22..79ad1e8ab 100644 --- a/lang/uk/analytics.php +++ b/lang/uk/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => 'Охоплення постів', 'quotes' => 'Цитати', 'reach' => 'Охоплення', + 'reactions' => 'Реакції', 'reblogs' => 'Репости', 'recent_comments' => 'Нещодавні коментарі', 'recent_likes' => 'Нещодавні вподобання', diff --git a/lang/zh/analytics.php b/lang/zh/analytics.php index d9e81278d..539d2de22 100644 --- a/lang/zh/analytics.php +++ b/lang/zh/analytics.php @@ -35,6 +35,7 @@ 'posts_reach' => '帖子触达', 'quotes' => '引用', 'reach' => '触达', + 'reactions' => '互动表情', 'reblogs' => '转发', 'recent_comments' => '近期评论', 'recent_likes' => '近期点赞', diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index 9863ce75d..037be8aa6 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -86,8 +86,22 @@ export const getPlatformTheme = (platform: string): { bg: string; rotate: string export const getPlatformLabel = (platform: string): string => PLATFORM_LABELS[platform] ?? platform; +const translationKeyFor = (contentType: string): string => `posts.content_types.${contentType}.label`; + export const getContentTypeOptions = (platform: string): ContentTypeOption[] => (PLATFORM_CONTENT_TYPES[platform] ?? []).map((value) => ({ value, - labelKey: `posts.content_types.${value}.label`, + labelKey: translationKeyFor(value), })); + +/** Whether the user picks a format on this platform, or it only has one. */ +export const hasMultipleContentTypes = (platform: string): boolean => + getContentTypeOptions(platform).length > 1; + +/** + * Translation key for the badge that names a published format, or null when + * the format was never a choice: tagging "Post" on X would just repeat the + * platform name. + */ +export const getContentTypeBadgeKey = (platform: string, contentType: string | null): string | null => + contentType && hasMultipleContentTypes(platform) ? translationKeyFor(contentType) : null; diff --git a/resources/js/pages/posts/Show.vue b/resources/js/pages/posts/Show.vue index a1d15d559..adf182764 100644 --- a/resources/js/pages/posts/Show.vue +++ b/resources/js/pages/posts/Show.vue @@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { usePostEcho } from '@/composables/echo/usePostEcho'; -import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; +import { getContentTypeBadgeKey, getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/usePostStatus'; import date from '@/date'; import AppLayout from '@/layouts/AppLayout.vue'; @@ -66,7 +66,11 @@ const props = defineProps<{ post: Post; }>(); -const enabledPlatforms = computed(() => props.post.platforms.filter((pp) => pp.enabled)); +const enabledPlatforms = computed(() => + props.post.platforms + .filter((pp) => pp.enabled) + .map((pp) => ({ ...pp, contentTypeBadgeKey: getContentTypeBadgeKey(pp.platform, pp.content_type) })), +); const isPublishing = computed(() => props.post.status === PostStatus.Publishing); @@ -239,7 +243,16 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
-

{{ getDisplayName(pp) }}

+
+

{{ getDisplayName(pp) }}

+ + {{ $t(pp.contentTypeBadgeKey) }} + +

@{{ getDisplayUsername(pp) }} · {{ getPlatformLabel(pp.platform) }} diff --git a/tests/Browser/PostShowContentTypeTest.php b/tests/Browser/PostShowContentTypeTest.php new file mode 100644 index 000000000..392304ea2 --- /dev/null +++ b/tests/Browser/PostShowContentTypeTest.php @@ -0,0 +1,79 @@ +script(<< { + const sel = '[data-testid="{$testId}"]'; + for (let i = 0; i < 100; i++) { + const el = document.querySelector(sel); + if (el && el.getBoundingClientRect().height > 0) return; + await new Promise((r) => setTimeout(r, 50)); + } + })(); + JS); +} + +test('the post page tags the format only where the platform offered a choice', function () { + $user = User::factory()->create(); + $workspace = Workspace::factory()->create([ + 'user_id' => $user->id, + 'account_id' => $user->account_id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + subscribeAccount($user->account); + + $post = Post::factory()->create([ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'status' => PostStatus::Published, + 'content' => 'Published everywhere', + ]); + + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => SocialAccount::factory()->facebook()->create(['workspace_id' => $workspace->id])->id, + 'platform' => Platform::Facebook, + 'content_type' => ContentType::FacebookReel, + 'status' => PostPlatformStatus::Published, + 'enabled' => true, + ]); + + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id, 'platform' => Platform::LinkedIn])->id, + 'platform' => Platform::LinkedIn, + 'content_type' => ContentType::LinkedInPost, + 'status' => PostPlatformStatus::Published, + 'enabled' => true, + ]); + + $this->actingAs($user); + + $page = visit(route('app.posts.show', $post)); + + waitForPostShowTestId($page, 'content-type-facebook_reel'); + + $page->assertVisible('@content-type-facebook_reel') + ->assertMissing('@content-type-linkedin_post') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index e68ea13e5..3929baad2 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -1151,6 +1151,33 @@ ); }); +test('show page exposes the content type of each platform', function () { + $facebookAccount = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Published, + ]); + + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $facebookAccount->id, + 'platform' => Platform::Facebook, + 'content_type' => ContentType::FacebookReel, + 'enabled' => true, + ]); + + $this->actingAs($this->user) + ->get(route('app.posts.show', $post)) + ->assertInertia(fn ($page) => $page + ->component('posts/Show', false) + ->where('post.platforms.0.content_type', ContentType::FacebookReel->value) + ); +}); + test('show page redirects editable posts to edit', function () { foreach ([PostStatus::Draft, PostStatus::Scheduled] as $status) { $post = Post::factory()->create([ diff --git a/tests/Feature/Services/Social/FacebookAnalyticsTest.php b/tests/Feature/Services/Social/FacebookAnalyticsTest.php new file mode 100644 index 000000000..3b435df2d --- /dev/null +++ b/tests/Feature/Services/Social/FacebookAnalyticsTest.php @@ -0,0 +1,215 @@ +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, + ]); + $this->account = SocialAccount::factory()->facebook()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'page_123', + ]); + $this->graph = config('trypost.platforms.facebook.graph_api'); +}); + +/** + * @param array $metrics + * @return array + */ +function facebookInsightsResponse(array $metrics): array +{ + return [ + 'data' => array_map(fn (array $metric): array => [ + 'name' => $metric['name'], + 'period' => 'lifetime', + 'values' => [['value' => $metric['value']]], + ], $metrics), + ]; +} + +function facebookPostPlatform(ContentType $contentType, string $platformPostId): PostPlatform +{ + return PostPlatform::factory()->create([ + 'post_id' => test()->post->id, + 'social_account_id' => test()->account->id, + 'platform' => Platform::Facebook, + 'content_type' => $contentType, + 'platform_post_id' => $platformPostId, + ]); +} + +test('facebook analytics reads feed post metrics from the post insights edge', function () { + Http::fake([ + "{$this->graph}/page_123_post_456/insights*" => Http::response(facebookInsightsResponse([ + ['name' => 'post_media_view', 'value' => 120], + ['name' => 'post_total_media_view_unique', 'value' => 90], + ['name' => 'post_reactions_like_total', 'value' => 7], + ['name' => 'post_clicks', 'value' => 3], + ])), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookPost, 'page_123_post_456')); + + expect($metrics)->toBe([ + ['label' => 'Impressions', 'value' => 120], + ['label' => 'Reach', 'value' => 90], + ['label' => 'Likes', 'value' => 7], + ['label' => 'Clicks', 'value' => 3], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/page_123_post_456/insights") + && $request['metric'] === 'post_media_view,post_total_media_view_unique,post_reactions_like_total,post_clicks' + && $request['period'] === 'lifetime'); +}); + +test('facebook analytics asks every node for lifetime values only', function (ContentType $contentType, string $platformPostId, string $edge) { + Http::fake(); + + (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform($contentType, $platformPostId)); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/{$platformPostId}/{$edge}") + && $request['period'] === 'lifetime'); +})->with([ + 'feed post' => [ContentType::FacebookPost, 'page_123_post_456', 'insights'], + 'timeline video' => [ContentType::FacebookPost, '2984721568539922', 'video_insights'], + 'reel' => [ContentType::FacebookReel, '2984721568539922', 'video_insights'], + 'story' => [ContentType::FacebookStory, 'story_post_123', 'insights'], +]); + +test('facebook analytics does not request the deprecated post_impressions metrics', function () { + Http::fake(); + + (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookPost, 'page_123_post_456')); + + Http::assertNotSent(fn ($request) => str_contains($request['metric'] ?? '', 'post_impressions')); +}); + +test('facebook analytics reads a bare video id from the video insights edge', function (ContentType $contentType) { + Http::fake([ + "{$this->graph}/2984721568539922/video_insights*" => Http::response(facebookInsightsResponse([ + ['name' => 'fb_reels_total_plays', 'value' => 15], + ['name' => 'post_video_likes_by_reaction_type', 'value' => ['REACTION_LIKE' => 4, 'REACTION_LOVE' => 2]], + ['name' => 'post_video_social_actions', 'value' => ['COMMENT' => 1, 'SHARE' => 2]], + ])), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform($contentType, '2984721568539922')); + + expect($metrics)->toBe([ + ['label' => 'Video Views', 'value' => 15], + ['label' => 'Reactions', 'value' => 6], + ['label' => 'Interactions', 'value' => 3], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/2984721568539922/video_insights") + && $request['metric'] === 'fb_reels_total_plays,post_video_likes_by_reaction_type,post_video_social_actions'); + + Http::assertNotSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/2984721568539922/insights")); +})->with([ + 'reel' => ContentType::FacebookReel, + 'timeline video' => ContentType::FacebookPost, +]); + +test('facebook analytics reads an empty video breakdown as zero', function () { + Http::fake([ + "{$this->graph}/2984721568539922/video_insights*" => Http::response(facebookInsightsResponse([ + ['name' => 'fb_reels_total_plays', 'value' => 14], + ['name' => 'post_video_likes_by_reaction_type', 'value' => []], + ['name' => 'post_video_social_actions', 'value' => []], + ])), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookPost, '2984721568539922')); + + expect($metrics)->toBe([ + ['label' => 'Video Views', 'value' => 14], + ['label' => 'Reactions', 'value' => 0], + ['label' => 'Interactions', 'value' => 0], + ]); +}); + +test('facebook analytics reads story metrics with the story metric family', function () { + Http::fake([ + "{$this->graph}/story_post_123/insights*" => Http::response(facebookInsightsResponse([ + ['name' => 'page_story_impressions_by_story_id', 'value' => 40], + ['name' => 'page_story_impressions_by_story_id_unique', 'value' => 35], + ['name' => 'story_interaction', 'value' => 6], + ['name' => 'pages_fb_story_thread_lightweight_reactions', 'value' => 3], + ['name' => 'pages_fb_story_replies', 'value' => 2], + ['name' => 'pages_fb_story_shares', 'value' => 1], + ])), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookStory, 'story_post_123')); + + expect($metrics)->toBe([ + ['label' => 'Impressions', 'value' => 40], + ['label' => 'Reach', 'value' => 35], + ['label' => 'Interactions', 'value' => 6], + ['label' => 'Reactions', 'value' => 3], + ['label' => 'Replies', 'value' => 2], + ['label' => 'Shares', 'value' => 1], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/story_post_123/insights") + && ! str_contains($request['metric'], 'post_')); +}); + +test('facebook analytics ignores metrics it did not ask for', function () { + Http::fake([ + "{$this->graph}/page_123_post_456/insights*" => Http::response(facebookInsightsResponse([ + ['name' => 'post_media_view', 'value' => 12], + ['name' => 'post_some_new_metric', 'value' => 99], + ['name' => 'post_clicks', 'value' => 1], + ])), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookPost, 'page_123_post_456')); + + expect($metrics)->toBe([ + ['label' => 'Impressions', 'value' => 12], + ['label' => 'Clicks', 'value' => 1], + ]); +}); + +test('facebook analytics reports an api rejection as unsupported', function () { + Http::fake([ + "{$this->graph}/story_post_123/insights*" => Http::response([ + 'error' => ['message' => '(#100) Param metric[0] must be one of {...}', 'code' => 100], + ], 400), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookStory, 'story_post_123')); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'api_error']); +}); + +test('facebook analytics reports a missing platform post id as unsupported', function () { + Http::fake(); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->account->id, + 'platform' => Platform::Facebook, + 'content_type' => ContentType::FacebookPost, + 'platform_post_id' => null, + ])); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 1a1ab1ba2..bfe06fe97 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\FacebookPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; @@ -12,8 +13,10 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Social\FacebookPublisher; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Sleep; use Intervention\Image\Drivers\Gd\Driver; use Intervention\Image\ImageManager; @@ -25,7 +28,56 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80); } +/** + * @return array> + */ +function facebookVideoMedia(): array +{ + return [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/video.mp4', + 'url' => 'https://example.com/media/2026-01/video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'video.mp4', + ], + ]; +} + +/** + * Happy-path fakes for Meta's resumable video flow on a Page edge: start hands + * back the rupload URL, rupload accepts the hosted file, the status poll + * reports the fetch complete, finish publishes. + * + * @return array + */ +function facebookVideoUploadFakes(string $edge): array +{ + $graph = config('trypost.platforms.facebook.graph_api'); + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + + return [ + "*/page_123/{$edge}" => Http::sequence() + ->push([ + 'video_id' => 'video_123', + 'upload_url' => "{$rupload}/video-upload/v25.0/video_123", + ], 200) + ->push(['success' => true, 'id' => 'reel_456', 'post_id' => 'story_456'], 200), + "{$rupload}/*" => Http::response(['success' => true], 200), + "{$graph}/video_123?fields=status*" => Http::response([ + 'status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'complete']], + ], 200), + ]; +} + +dataset('facebook resumable video formats', [ + 'reel' => [ContentType::FacebookReel, 'video_reels'], + 'story' => [ContentType::FacebookStory, 'video_stories'], +]); + beforeEach(function () { + Sleep::fake(); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); @@ -201,165 +253,387 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string }); }); -test('facebook publisher can publish reel', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); +test('facebook publisher rejects image story', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); $this->post->update([ - 'media' => [ [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', + 'id' => 'test-media-story', + 'path' => 'media/2026-01/story.jpg', + 'url' => 'https://example.com/media/2026-01/story.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'story.jpg', ], ], - ]); - Http::fake([ - '*/page_123/video_reels' => Http::sequence() - ->push([ - 'video_id' => 'reel_video_123', - 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123', - ], 200) - ->push(['id' => 'reel_123', 'success' => true], 200), - '*example.com/media/*' => Http::response('fake-video-binary-content', 200), - '*rupload.facebook.com/*' => Http::response(['success' => true], 200), - ]); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook Stories require a video file.'); +}); + +test('facebook publisher rejects a reel without a video', function (array $media) { + $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + $this->post->update(['media' => $media]); + + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook Reels require a video file.'); + + Http::assertNothingSent(); +})->with([ + 'no media' => [[]], + 'image' => [[[ + 'id' => 'test-media-image', + 'path' => 'media/2026-01/image.jpg', + 'url' => 'https://example.com/media/2026-01/image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'image.jpg', + ]]], +]); + +test('facebook publisher rejects a story without media', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => []]); + + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook Stories require a video file.'); + + Http::assertNothingSent(); +}); + +test('facebook publisher can publish reel', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake(facebookVideoUploadFakes('video_reels')); $result = $this->publisher->publish($this->postPlatform); - expect($result)->toHaveKey('id'); - expect($result['id'])->toBe('reel_123'); - expect($result['url'])->toBe('https://www.facebook.com/reel/reel_123'); + expect($result['id'])->toBe('reel_456'); + expect($result['url'])->toBe('https://www.facebook.com/reel/reel_456'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/video_reels') + && $request['upload_phase'] === 'finish' + && $request['video_id'] === 'video_123' + && $request['video_state'] === 'PUBLISHED' + && $request['description'] === 'Check out this Facebook post!'); +}); + +test('facebook publisher can publish video story', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake(facebookVideoUploadFakes('video_stories')); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('story_456'); + expect($result['url'])->toBe('https://www.facebook.com/stories/page_123/story_456'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + && $request['upload_phase'] === 'finish' + && $request['video_id'] === 'video_123' + && ! array_key_exists('video_state', $request->data())); +}); + +test('facebook publisher hands meta the hosted url and never downloads the video', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake(facebookVideoUploadFakes($edge)); + + $this->publisher->publish($this->postPlatform); - // Assert the transfer phase: POST raw bytes to upload_url (rupload - // host) with OAuth header and the required Offset + file_size - // headers Facebook's rupload validator demands. Http::assertSent(function ($request) { - if (! str_contains($request->url(), 'rupload.facebook.com')) { + if (! str_contains($request->url(), config('trypost.platforms.facebook.rupload_host'))) { return false; } - return ($request->header('Offset')[0] ?? null) === '0' - && ($request->header('file_size')[0] ?? null) === (string) strlen('fake-video-binary-content') - && str_starts_with($request->header('Authorization')[0] ?? '', 'OAuth '); + return $request->method() === 'POST' + && ($request->header('file_url')[0] ?? null) === 'https://example.com/media/2026-01/video.mp4' + && str_starts_with($request->header('Authorization')[0] ?? '', 'OAuth ') + && $request->body() === ''; }); -}); -test('facebook publisher fails reel publish when start does not return upload_url', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.com/media')); - $this->post->update([ - 'media' => [ - [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', - ], - ], + Http::assertNotSent(fn ($request) => $request->method() === 'POST' + && str_contains($request->url(), config('trypost.platforms.facebook.graph_api').'/video_123')); + + Sleep::assertNeverSlept(); + + expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); +})->with('facebook resumable video formats'); + +test('facebook publisher waits for meta to fetch the video before finishing', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::sequence() + ->push(['status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'not_started']]], 200) + ->push(['status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'in_progress', 'bytes_transfered' => 1024]]], 200) + ->push(['status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'complete']]], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Sleep::assertSleptTimes(2); + Sleep::assertSequence([ + Sleep::for(5)->seconds(), + Sleep::for(5)->seconds(), + ]); +})->with('facebook resumable video formats'); + +test('facebook publisher fails when start does not return upload_url', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake([ + "*/page_123/{$edge}" => Http::response(['video_id' => 'video_123'], 200), ]); - // Missing upload_url in the start response — should not silently - // proceed to a broken transfer (which is what the old code did). + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook did not start the video upload. Please try again.'); + + Http::assertSentCount(1); +})->with('facebook resumable video formats'); + +test('facebook publisher rejects an upload_url outside the rupload host', function (ContentType $contentType, string $edge, string $uploadUrl) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + Http::fake([ - '*/page_123/video_reels' => Http::response([ - 'video_id' => 'reel_video_123', + "*/page_123/{$edge}" => Http::response([ + 'video_id' => 'video_123', + 'upload_url' => $uploadUrl, ], 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow( - FacebookPublishException::class, - 'Facebook did not return upload_url for reel start.' - ); + ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + + Http::assertSentCount(1); +})->with('facebook resumable video formats')->with([ + 'another host' => 'https://evil.example/steal-token', + 'plain http' => 'http://rupload.facebook.com/video-upload/v25.0/video_123', + 'userinfo trick' => 'https://rupload.facebook.com@evil.example/video-upload/v25.0/video_123', + 'not a url' => 'video_123', +]); + +test('facebook publisher trusts the rupload host from config', function () { + config()->set('trypost.platforms.facebook.rupload_host', 'rupload.example.test'); + + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookVideoMedia()]); + + Http::fake([ + ...facebookVideoUploadFakes('video_stories'), + 'https://rupload.example.test/*' => Http::response(['success' => true], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('story_456'); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://rupload.example.test/')); }); -test('facebook publisher fails reel publish with typed exception when media download fails', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); +test('facebook publisher maps a rupload rejection and does not finish', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); - $this->post->update([ - 'media' => [ - [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', - ], - ], + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$rupload}/*" => Http::response(['error' => ['message' => 'Problem with file', 'code' => 6000]], 400), ]); - // start succeeds, but the media URL returns 404 — should surface as - // a typed FacebookPublishException (ServerError) instead of leaking - // a generic Exception that would land in the 'unknown' bucket. + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Problem with file. Try with another file.'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); + +test('facebook publisher does not finish when rupload does not confirm success', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + Http::fake([ - '*/page_123/video_reels' => Http::response([ - 'video_id' => 'reel_video_123', - 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123', - ], 200), - '*example.com/media/*' => Http::response('', 404), + ...facebookVideoUploadFakes($edge), + "{$rupload}/*" => Http::response(['success' => false], 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow( - FacebookPublishException::class, - 'Could not download media for Facebook reel.' - ); -}); + ->toThrow(FacebookPublishException::class, 'Facebook did not accept the video. Please try again.'); -test('facebook publisher rejects image story', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); - $this->post->update([ - 'media' => [ - [ - 'id' => 'test-media-story', - 'path' => 'media/2026-01/story.jpg', - 'url' => 'https://example.com/media/2026-01/story.jpg', - 'mime_type' => 'image/jpeg', - 'original_filename' => 'story.jpg', - ], - ], +test('facebook publisher reschedules when rupload cannot be reached', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$rupload}/*" => fn () => throw new ConnectionException('cURL error 28: Connection timed out after 10003 milliseconds'), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook Stories require a video file.'); -}); + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->retryDelaySeconds)->toBe(60) + ->and($exception->getMessage())->toContain('video upload unreachable'); + }); -test('facebook publisher can publish video story', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); - $this->post->update([ +test('facebook publisher reschedules when the status check cannot be reached and keeps the token out of the message', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); - 'media' => [ - [ - 'id' => 'test-media-video-story', - 'path' => 'media/2026-01/story.mp4', - 'url' => 'https://example.com/media/2026-01/story.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'story.mp4', + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => fn ($request) => throw new ConnectionException("cURL error 28: Operation timed out for {$request->url()}"), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->getMessage()) + ->toContain('video status unreachable') + ->toContain('access_token=[REDACTED]') + ->not->toContain($this->socialAccount->access_token); + }); +})->with('facebook resumable video formats'); + +test('facebook publisher keeps polling the video status through a transient graph error', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::sequence() + ->push(['error' => ['message' => 'Service temporarily unavailable', 'code' => 2]], 500) + ->push(['status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'complete']]], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Sleep::assertSleptTimes(1); +})->with('facebook resumable video formats'); + +test('facebook publisher stops polling the video status on a confirmed graph rejection', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::response([ + 'error' => ['message' => 'Unsupported get request.', 'type' => 'GraphMethodException', 'code' => 100], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Unsupported get request.'); + + Sleep::assertNeverSlept(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); + +test('facebook publisher surfaces the video processing error instead of finishing', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::response([ + 'status' => [ + 'video_status' => 'processing', + 'uploading_phase' => ['status' => 'complete'], + 'processing_phase' => [ + 'status' => 'not_started', + 'error' => ['message' => 'Resolution too low. Video must have a minimum resolution of 540p.'], + ], ], - ], + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Resolution too low. Video must have a minimum resolution of 540p.'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); + +test('facebook publisher fails when the upload session expires', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + Http::fake([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::response(['status' => ['video_status' => 'expired']], 200), ]); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook could not process the video. Please try another file.'); +})->with('facebook resumable video formats'); + +test('facebook publisher gives up on a video fetch that never completes', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + Http::fake([ - '*/page_123/video_stories' => Http::sequence() - ->push(['video_id' => 'story_video_123'], 200) - ->push(['post_id' => 'video_story_post_123'], 200), - '*/story_video_123' => Http::response(['success' => true], 200), - '*' => Http::response('', 200), + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::response([ + 'status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'in_progress']], + ], 200), ]); - $result = $this->publisher->publish($this->postPlatform); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook took too long to fetch the video. Please try again.'); - expect($result)->toHaveKey('id'); - expect($result['id'])->toBe('video_story_post_123'); + Sleep::assertSleptTimes(59); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); + +test('facebook publisher reschedules a graph post when facebook cannot be reached', function () { + Http::fake([ + '*/page_123/feed' => fn () => throw new ConnectionException('cURL error 28: Connection timed out'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(PlatformUnavailableException::class); }); test('facebook publisher throws exception on api error', function () { @@ -439,6 +713,48 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string ->toThrow(Exception::class, 'Failed to upload any images to Facebook'); }); +test('facebook publisher publishes the multi image post with the photos facebook accepted', function () { + $mediaItems = []; + for ($i = 1; $i <= 3; $i++) { + $mediaItems[] = [ + 'id' => "test-media-{$i}", + 'path' => "media/2026-01/image{$i}.jpg", + 'url' => "https://example.com/media/2026-01/image{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'original_filename' => "image{$i}.jpg", + ]; + } + $this->post->update(['media' => $mediaItems]); + + Http::fake([ + '*/page_123/photos' => Http::sequence() + ->push(['id' => 'photo_1'], 200) + ->push(['error' => ['message' => 'Upload failed', 'code' => 100]], 400) + ->push(['id' => 'photo_3'], 200), + '*/page_123/feed' => Http::response(['id' => 'page_123_partial_789'], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('page_123_partial_789'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/feed') + && ($request->data()['attached_media[0]'] ?? null) === json_encode(['media_fbid' => 'photo_1']) + && ($request->data()['attached_media[1]'] ?? null) === json_encode(['media_fbid' => 'photo_3']) + && ! array_key_exists('attached_media[2]', $request->data())); +}); + +test('facebook publisher rejects a text post that is only whitespace', function () { + $this->post->update(['content' => " \n\t "]); + + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook text posts require content'); + + Http::assertNothingSent(); +}); + test('facebook publisher throws exception for unsupported media type', function () { $this->post->update([ 'media' => [ @@ -463,43 +779,6 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string ->toThrow(Exception::class, 'Facebook text posts require content'); }); -test('facebook publisher cleans up temp files after reel upload', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); - - $this->post->update([ - - 'media' => [ - [ - 'id' => 'test-media-reel', - 'path' => 'media/2026-01/reel.mp4', - 'url' => 'https://example.com/media/2026-01/reel.mp4', - 'mime_type' => 'video/mp4', - 'original_filename' => 'reel.mp4', - ], - ], - - ]); - - Http::fake([ - '*/page_123/video_reels' => Http::sequence() - ->push([ - 'video_id' => 'reel_video_cleanup_123', - 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_cleanup_123', - ], 200) - ->push(['id' => 'reel_cleanup_456', 'success' => true], 200), - '*example.com/media/*' => Http::response('fake-video', 200), - '*rupload.facebook.com/*' => Http::response(['success' => true], 200), - ]); - - $this->publisher->publish($this->postPlatform); - - // Assert no leftover fb_reel_ temp files exist - $tempDir = sys_get_temp_dir(); - $leftoverFiles = glob("{$tempDir}/fb_reel_*") ?: []; - - expect($leftoverFiles)->toBeEmpty(); -}); - test('facebook publisher can publish single image with null content', function () { $this->post->update([ 'content' => null, @@ -623,16 +902,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string ], ]); - Http::fake([ - '*/page_123/video_reels' => Http::sequence() - ->push([ - 'video_id' => 'reel_video_123', - 'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123', - ], 200) - ->push(['id' => 'reel_123', 'success' => true], 200), - '*example.com/media/*' => Http::response('fake-video-binary-content', 200), - '*rupload.facebook.com/*' => Http::response(['success' => true], 200), - ]); + Http::fake(facebookVideoUploadFakes('video_reels')); $this->publisher->publish($this->postPlatform);