From 13a2613c6e98b26e381c3bf1b9e2cbacf53c73c2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 09:31:43 -0300 Subject: [PATCH 01/15] Fix Facebook video stories by uploading through rupload with file_url The story transfer posted video_file_chunk= to the Graph /{video_id} node, a Page Videos API parameter the Stories flow never reads. Meta never fetched the file, the upload_url session stayed empty, and finish failed with error 6000 "Problem with file". Send the hosted URL as the file_url header to the upload_url the start phase returns, require success:true, then poll /{video_id}?fields=status until the uploading phase completes before finish. The upload_url host is pinned to config so the Page token never leaves rupload.facebook.com. Reels, feed posts, photos and timeline videos are untouched. --- app/Services/Social/FacebookPublisher.php | 118 ++++++++-- config/trypost.php | 1 + .../Services/Social/FacebookPublisherTest.php | 222 ++++++++++++++++-- 3 files changed, 309 insertions(+), 32 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 64a7e1a27..3cc700761 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -16,12 +16,17 @@ use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Sleep; class FacebookPublisher { use CropsImageForAspectRatio; use HasSocialHttpClient; + private const int STORY_UPLOAD_POLL_SECONDS = 5; + + private const int STORY_UPLOAD_MAX_POLLS = 60; + private string $baseUrl; public function __construct() @@ -375,35 +380,51 @@ private function publishStory(string $pageId, string $accessToken, $media): arra ); } - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ + $startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'start', 'access_token' => $accessToken, ]); - if ($response->failed()) { - $this->handleApiError($response); + if ($startResponse->failed()) { + $this->handleApiError($startResponse); } - $videoId = $response->json()['video_id'] ?? null; + $startData = $startResponse->json(); + $videoId = data_get($startData, 'video_id'); + $uploadUrl = data_get($startData, 'upload_url'); - if (! $videoId) { + if (! filled($videoId) || ! is_string($uploadUrl) || ! filled($uploadUrl)) { throw new FacebookPublishException( - userMessage: 'Facebook did not accept the story video. Please try again.', + userMessage: 'Facebook did not start the story upload. Please try again.', category: ErrorCategory::ServerError, + rawResponse: $startResponse->body(), ); } - $transferResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$videoId}", [ - 'upload_phase' => 'transfer', - 'video_file_chunk' => $media->url, - 'access_token' => $accessToken, - ]); + $this->assertRuploadUrl($uploadUrl); + + $uploadResponse = $this->socialHttp() + ->withHeaders([ + 'Authorization' => "OAuth {$accessToken}", + 'file_url' => $media->url, + ]) + ->send('POST', $uploadUrl); - if ($transferResponse->failed()) { - Log::error('Facebook video story transfer failed', ['body' => $this->redactResponseBody($transferResponse->body())]); - $this->handleApiError($transferResponse); + if ($uploadResponse->failed()) { + Log::error('Facebook video story upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); + $this->handleApiError($uploadResponse); } + if (data_get($uploadResponse->json(), 'success') !== true) { + throw new FacebookPublishException( + userMessage: 'Facebook did not accept the story video. Please try again.', + category: ErrorCategory::ServerError, + rawResponse: $uploadResponse->body(), + ); + } + + $this->waitForStoryUpload((string) $videoId, $accessToken); + $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ 'upload_phase' => 'finish', 'video_id' => $videoId, @@ -414,7 +435,7 @@ private function publishStory(string $pageId, string $accessToken, $media): arra $this->handleApiError($finishResponse); } - $storyId = $finishResponse->json()['post_id'] ?? $videoId; + $storyId = data_get($finishResponse->json(), 'post_id', $videoId); return [ 'id' => $storyId, @@ -422,6 +443,73 @@ private function publishStory(string $pageId, string $accessToken, $media): arra ]; } + /** + * The story `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 + { + $parts = parse_url($uploadUrl); + + if (data_get($parts, 'scheme') !== 'https' || data_get($parts, 'host') !== config('trypost.platforms.facebook.rupload_host')) { + throw new FacebookPublishException( + userMessage: 'Facebook returned an invalid upload URL.', + category: ErrorCategory::ServerError, + rawResponse: $uploadUrl, + ); + } + } + + /** + * With `file_url` Meta fetches the video from our CDN asynchronously, so + * the rupload POST returns before the bytes exist on their side. Calling + * `finish` on that empty session is what produced error 6000; wait until + * the uploading phase reports complete. + */ + private function waitForStoryUpload(string $videoId, string $accessToken): void + { + for ($attempt = 0; $attempt < self::STORY_UPLOAD_MAX_POLLS; $attempt++) { + $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ + 'fields' => 'status', + 'access_token' => $accessToken, + ]); + + if ($statusResponse->failed()) { + $this->handleApiError($statusResponse); + } + + $status = data_get($statusResponse->json(), 'status', []); + $videoStatus = data_get($status, 'video_status'); + $uploadingStatus = data_get($status, 'uploading_phase.status'); + $detail = data_get($status, 'processing_phase.error.message') + ?? data_get($status, 'uploading_phase.error.message'); + + if ($detail !== null + || in_array($videoStatus, ['error', 'expired'], true) + || $uploadingStatus === 'error' + || data_get($status, 'processing_phase.status') === 'error') { + throw new FacebookPublishException( + userMessage: is_string($detail) && $detail !== '' + ? $detail + : 'Facebook could not process the story video. Please try another file.', + category: ErrorCategory::MediaFormat, + rawResponse: $statusResponse->body(), + ); + } + + if ($uploadingStatus === 'complete' || in_array($videoStatus, ['ready', 'upload_complete'], true)) { + return; + } + + Sleep::for(self::STORY_UPLOAD_POLL_SECONDS)->seconds(); + } + + throw new FacebookPublishException( + userMessage: 'Facebook took too long to fetch the story video. Please try again.', + category: ErrorCategory::ServerError, + ); + } + private function handleApiError(Response $response): never { throw FacebookPublishException::fromApiResponse($response); 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/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 1a1ab1ba2..0e482196c 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -14,6 +14,7 @@ use App\Services\Social\FacebookPublisher; 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 +26,51 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80); } +/** + * @return array> + */ +function facebookStoryVideoMedia(): array +{ + return [ + [ + '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', + ], + ]; +} + +/** + * Points the post at a single hosted story video and returns the fakes for the + * happy path: start hands back the rupload URL, rupload accepts, the status + * poll reports the upload complete, finish publishes. + * + * @return array + */ +function facebookStoryFakes(): array +{ + $graph = config('trypost.platforms.facebook.graph_api'); + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + + return [ + '*/page_123/video_stories' => Http::sequence() + ->push([ + 'video_id' => 'story_video_123', + 'upload_url' => "{$rupload}/video-upload/v25.0/story_video_123", + ], 200) + ->push(['success' => true, 'post_id' => 'video_story_post_123'], 200), + "{$rupload}/*" => Http::response(['success' => true], 200), + "{$graph}/story_video_123?fields=status*" => Http::response([ + 'status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'complete']], + ], 200), + ]; +} + beforeEach(function () { + Sleep::fake(); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); @@ -333,33 +378,176 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string test('facebook publisher can publish video story', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); - $this->post->update([ + Http::fake(facebookStoryFakes()); - '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', - ], - ], + $result = $this->publisher->publish($this->postPlatform); - ]); + expect($result['id'])->toBe('video_story_post_123'); + expect($result['url'])->toBe('https://www.facebook.com/stories/page_123/video_story_post_123'); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), config('trypost.platforms.facebook.rupload_host'))) { + return false; + } + + return $request->method() === 'POST' + && ($request->header('file_url')[0] ?? null) === 'https://example.com/media/2026-01/story.mp4' + && str_starts_with($request->header('Authorization')[0] ?? '', 'OAuth ') + && $request->body() === ''; + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.com/media')); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST' + && str_contains($request->url(), config('trypost.platforms.facebook.graph_api').'/story_video_123')); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + && $request['upload_phase'] === 'finish' + && $request['video_id'] === 'story_video_123'); + + Sleep::assertNeverSlept(); +}); + +test('facebook publisher waits for the story upload before finishing', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $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), + ...facebookStoryFakes(), + "{$graph}/story_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), ]); $result = $this->publisher->publish($this->postPlatform); - expect($result)->toHaveKey('id'); expect($result['id'])->toBe('video_story_post_123'); + + Sleep::assertSleptTimes(2); + Sleep::assertSequence([ + Sleep::for(5)->seconds(), + Sleep::for(5)->seconds(), + ]); +}); + +test('facebook publisher fails story publish when start does not return upload_url', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + Http::fake([ + '*/page_123/video_stories' => Http::response(['video_id' => 'story_video_123'], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook did not start the story upload. Please try again.'); + + Http::assertSentCount(1); +}); + +test('facebook publisher rejects a story upload_url outside the rupload host', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + Http::fake([ + '*/page_123/video_stories' => Http::response([ + 'video_id' => 'story_video_123', + 'upload_url' => 'https://evil.example/steal-token', + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + + Http::assertSentCount(1); +}); + +test('facebook publisher does not finish the story when rupload does not confirm success', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + + Http::fake([ + ...facebookStoryFakes(), + "{$rupload}/*" => Http::response(['success' => false], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook did not accept the story video. Please try again.'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + && $request['upload_phase'] === 'finish'); +}); + +test('facebook publisher surfaces the story processing error instead of finishing', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookStoryFakes(), + "{$graph}/story_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/video_stories') + && $request['upload_phase'] === 'finish'); +}); + +test('facebook publisher fails the story when the upload session expires', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookStoryFakes(), + "{$graph}/story_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 story video. Please try another file.'); +}); + +test('facebook publisher gives up on a story upload that never completes', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookStoryFakes(), + "{$graph}/story_video_123?fields=status*" => Http::response([ + 'status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'in_progress']], + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook took too long to fetch the story video. Please try again.'); + + Sleep::assertSleptTimes(60); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + && $request['upload_phase'] === 'finish'); }); test('facebook publisher throws exception on api error', function () { From 30429df667595bdbf0187eeae6f3b667c6d770e6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 09:39:10 -0300 Subject: [PATCH 02/15] Tidy FacebookPublisher around shared Graph helpers Route every Graph POST through postToGraph so logging and error mapping live in one place, share the start phase between Reels and Stories (including the rupload host check), build optional payload fields inline, and type the media parameters. Reels and Stories now reject a missing or non-video media with a typed exception before any request. HTTP calls, payloads and headers are unchanged. --- app/Services/Social/FacebookPublisher.php | 636 +++++++++--------- .../Services/Social/FacebookPublisherTest.php | 65 +- 2 files changed, 396 insertions(+), 305 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 3cc700761..f06155dba 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Dto\MediaItem; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; @@ -14,6 +15,7 @@ use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; @@ -23,6 +25,8 @@ class FacebookPublisher use CropsImageForAspectRatio; use HasSocialHttpClient; + private const int VIDEO_TRANSFER_TIMEOUT_SECONDS = 600; + private const int STORY_UPLOAD_POLL_SECONDS = 5; private const int STORY_UPLOAD_MAX_POLLS = 60; @@ -35,32 +39,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, @@ -68,244 +63,252 @@ 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 $this->publishMultiImagePost($pageId, $accessToken, $content, $media, $aspectRatio); - } - throw new FacebookPublishException( - userMessage: 'Unsupported media type for Facebook', - category: ErrorCategory::MediaFormat, - ); + 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, + ), + }; } - private function publishTextPost(string $pageId, string $accessToken, string $content): array + /** + * @return array{id: mixed, url: string} + */ + private function publishTextPost(string $pageId, string $accessToken, ?string $content): array { - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", [ - 'message' => $content, - 'access_token' => $accessToken, - ]); - - if ($response->failed()) { - Log::error('Facebook text post failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); + if (! filled($content)) { + throw new FacebookPublishException( + userMessage: 'Facebook text posts require content. Please add text to your post.', + category: ErrorCategory::MediaFormat, + ); } - $data = $response->json(); - $postId = data_get($data, 'id'); + $response = $this->postToGraph("{$pageId}/feed", [ + 'message' => $content, + 'access_token' => $accessToken, + ], 'text post'); - return [ - 'id' => $postId, - 'url' => "https://www.facebook.com/{$postId}", - ]; + return $this->feedPostResult(data_get($response->json(), 'id')); } - private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array + /** + * @return array{id: mixed, url: string} + */ + private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, MediaItem $media, ?string $aspectRatio): array { - $payload = [ + $response = $this->postToGraph("{$pageId}/photos", [ 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'access_token' => $accessToken, - ]; - - if ($content !== null && $content !== '') { - $payload['message'] = $content; - } - - $alt = $media->altTextFor(Platform::Facebook); - - if ($alt !== null) { - $payload['alt_text_custom'] = $alt; - } - - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload); - - if ($response->failed()) { - Log::error('Facebook single image 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, 'post_id', 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 publishMultiImagePost(string $pageId, string $accessToken, ?string $content, $mediaCollection, ?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 { - // Upload each image as unpublished - $attachedMedia = []; - - foreach ($mediaCollection as $media) { - if (! $media->isImage()) { - continue; - } - - $uploadPayload = [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), - 'published' => 'false', - 'access_token' => $accessToken, - ]; - - $alt = $media->altTextFor(Platform::Facebook); - - if ($alt !== null) { - $uploadPayload['alt_text_custom'] = $alt; - } - - $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $uploadPayload); + $photoIds = $media + ->filter(fn (MediaItem $item): bool => $item->isImage()) + ->map(fn (MediaItem $item): ?string => $this->uploadUnpublishedPhoto($pageId, $accessToken, $item, $aspectRatio)) + ->filter() + ->values(); - if ($uploadResponse->failed()) { - Log::error('Facebook image upload failed', [ - 'body' => $this->redactResponseBody($uploadResponse->body()), - ]); - - continue; - } - - $uploadData = $uploadResponse->json(); - $attachedMedia[] = ['media_fbid' => $uploadData['id']]; - } - - if (empty($attachedMedia)) { + if ($photoIds->isEmpty()) { throw new FacebookPublishException( userMessage: 'Failed to upload any images to Facebook', category: ErrorCategory::ServerError, ); } - // Create the post with attached media - $postData = [ + $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 ($content !== null && $content !== '') { - $postData['message'] = $content; - } - - foreach ($attachedMedia as $index => $media) { - $postData["attached_media[{$index}]"] = json_encode($media); - } + return $this->feedPostResult(data_get($response->json(), 'id')); + } - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData); + private function uploadUnpublishedPhoto(string $pageId, string $accessToken, MediaItem $media, ?string $aspectRatio): ?string + { + $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), + 'published' => 'false', + 'access_token' => $accessToken, + ...$this->altText($media), + ]); if ($response->failed()) { - Log::error('Facebook multi-image post failed', [ - 'status' => $response->status(), + Log::error('Facebook image upload failed', [ 'body' => $this->redactResponseBody($response->body()), ]); - $this->handleApiError($response); + + return null; } - $data = $response->json(); - $postId = data_get($data, 'id'); + $photoId = data_get($response->json(), 'id'); + + return is_string($photoId) && $photoId !== '' ? $photoId : null; + } + + /** + * @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 publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array + /** + * @return array{id: mixed, url: string} + */ + private function publishReel(string $pageId, string $accessToken, ?string $content, MediaItem $media): array { - $payload = [ - 'file_url' => $media->url, + [$videoId, $uploadUrl] = $this->startVideoUpload($pageId, $accessToken, 'video_reels'); + + $this->uploadVideoBytes($uploadUrl, $accessToken, $media); + + $response = $this->postToGraph("{$pageId}/video_reels", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'video_state' => 'PUBLISHED', 'access_token' => $accessToken, + ...$this->optionalField('description', $content), + ], 'reel finish'); + + $reelId = data_get($response->json(), 'id', $videoId); + + return [ + 'id' => $reelId, + 'url' => "https://www.facebook.com/reel/{$reelId}", ]; + } - if ($content !== null && $content !== '') { - $payload['description'] = $content; - } + /** + * @return array{id: mixed, url: string} + */ + private function publishStory(string $pageId, string $accessToken, MediaItem $media): array + { + [$videoId, $uploadUrl] = $this->startVideoUpload($pageId, $accessToken, 'video_stories'); - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload); + $this->uploadVideoFromUrl($uploadUrl, $accessToken, $media); + $this->waitForStoryUpload($videoId, $accessToken); - if ($response->failed()) { - Log::error('Facebook video post failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); - } + $response = $this->postToGraph("{$pageId}/video_stories", [ + 'upload_phase' => 'finish', + 'video_id' => $videoId, + 'access_token' => $accessToken, + ], 'story finish'); - $data = $response->json(); - $videoId = data_get($data, 'id'); + $storyId = data_get($response->json(), 'post_id', $videoId); return [ - 'id' => $videoId, - 'url' => "https://www.facebook.com/{$pageId}/videos/{$videoId}", + 'id' => $storyId, + 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", ]; } - private function publishReel(string $pageId, string $accessToken, ?string $content, $media): array + /** + * Phase 1 of Meta's resumable video flow, shared by Reels and Stories: the + * Graph edge opens a session and returns the rupload URL the file must go to. + * + * @return array{0: string, 1: string} + */ + private function startVideoUpload(string $pageId, string $accessToken, string $edge): array { - // Phase 1 (start) — graph endpoint returns video_id + upload_url. - $startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [ + $response = $this->postToGraph("{$pageId}/{$edge}", [ 'upload_phase' => 'start', 'access_token' => $accessToken, - ]); + ], "{$edge} start"); + + $videoId = data_get($response->json(), 'video_id'); + $uploadUrl = data_get($response->json(), 'upload_url'); - if ($startResponse->failed()) { - $this->handleApiError($startResponse); + 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(), + ); } - $startData = $startResponse->json(); - $videoId = data_get($startData, 'video_id'); - $uploadUrl = data_get($startData, 'upload_url'); + $this->assertRuploadUrl($uploadUrl); + + return [(string) $videoId, $uploadUrl]; + } - if (! $videoId || ! $uploadUrl) { + /** + * 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 + { + $parts = parse_url($uploadUrl); + + if (data_get($parts, 'scheme') !== 'https' || data_get($parts, 'host') !== config('trypost.platforms.facebook.rupload_host')) { throw new FacebookPublishException( - userMessage: 'Facebook did not return upload_url for reel start.', + userMessage: 'Facebook returned an invalid upload URL.', category: ErrorCategory::ServerError, - platformErrorCode: null, - rawResponse: $startResponse->body(), + rawResponse: $uploadUrl, ); } + } - // 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). + /** + * Reels still stream the file through the worker. The hosted `file_url` + * shortcut is only verified on Stories; the Reels session rejected it in + * the past ("Header Offset not convertable to unsigned long"). + */ + private function uploadVideoBytes(string $uploadUrl, string $accessToken, MediaItem $media): void + { $tempFile = tempnam(sys_get_temp_dir(), 'fb_reel_'); + if ($tempFile === false) { + throw $this->videoPreparationException(); + } + try { $download = Http::withOptions(['sink' => $tempFile]) - ->timeout(600) + ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) ->get($media->url); if ($download->failed()) { @@ -313,12 +316,15 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte userMessage: 'Could not download media for Facebook reel.', category: ErrorCategory::ServerError, platformErrorCode: (string) $download->status(), - rawResponse: null, ); } $fileSize = filesize($tempFile); - $stream = fopen($tempFile, 'rb'); + $stream = $fileSize !== false && $fileSize > 0 ? fopen($tempFile, 'rb') : false; + + if ($stream === false) { + throw $this->videoPreparationException(); + } try { $uploadResponse = Http::withHeaders([ @@ -326,7 +332,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte 'Offset' => '0', 'file_size' => (string) $fileSize, ]) - ->timeout(600) + ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) ->withBody($stream, $media->mime_type ?? 'video/mp4') ->post($uploadUrl); } finally { @@ -339,165 +345,71 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte $this->handleApiError($uploadResponse); } } finally { - if (! unlink($tempFile)) { + if (file_exists($tempFile) && ! unlink($tempFile)) { Log::warning('Facebook reel temp file cleanup failed', ['path' => $tempFile]); } } - - // 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; - } - - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload); - - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); - } - - $finishData = $finishResponse->json(); - $reelId = $finishData['id'] ?? $videoId; - - return [ - 'id' => $reelId, - 'url' => "https://www.facebook.com/reel/{$reelId}", - ]; } - private function publishStory(string $pageId, string $accessToken, $media): array + /** + * Stories hand Meta the CDN URL and let it fetch the file (Page Stories API + * hosted-file upload). The request is the two headers and no body. + */ + private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, MediaItem $media): void { - if (! $media->isVideo()) { - throw new FacebookPublishException( - userMessage: 'Facebook Stories require a video file.', - category: ErrorCategory::MediaFormat, - ); - } - - $startResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - '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'); - - if (! filled($videoId) || ! is_string($uploadUrl) || ! filled($uploadUrl)) { - throw new FacebookPublishException( - userMessage: 'Facebook did not start the story upload. Please try again.', - category: ErrorCategory::ServerError, - rawResponse: $startResponse->body(), - ); - } - - $this->assertRuploadUrl($uploadUrl); - - $uploadResponse = $this->socialHttp() + $response = $this->socialHttp() ->withHeaders([ 'Authorization' => "OAuth {$accessToken}", 'file_url' => $media->url, ]) ->send('POST', $uploadUrl); - if ($uploadResponse->failed()) { - Log::error('Facebook video story upload failed', ['body' => $this->redactResponseBody($uploadResponse->body())]); - $this->handleApiError($uploadResponse); + if ($response->failed()) { + Log::error('Facebook video story upload failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); } - if (data_get($uploadResponse->json(), 'success') !== true) { + if (data_get($response->json(), 'success') !== true) { throw new FacebookPublishException( userMessage: 'Facebook did not accept the story video. Please try again.', category: ErrorCategory::ServerError, - rawResponse: $uploadResponse->body(), - ); - } - - $this->waitForStoryUpload((string) $videoId, $accessToken); - - $finishResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/video_stories", [ - 'upload_phase' => 'finish', - 'video_id' => $videoId, - 'access_token' => $accessToken, - ]); - - if ($finishResponse->failed()) { - $this->handleApiError($finishResponse); - } - - $storyId = data_get($finishResponse->json(), 'post_id', $videoId); - - return [ - 'id' => $storyId, - 'url' => "https://www.facebook.com/stories/{$pageId}/{$storyId}", - ]; - } - - /** - * The story `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 - { - $parts = parse_url($uploadUrl); - - if (data_get($parts, 'scheme') !== 'https' || data_get($parts, 'host') !== config('trypost.platforms.facebook.rupload_host')) { - throw new FacebookPublishException( - userMessage: 'Facebook returned an invalid upload URL.', - category: ErrorCategory::ServerError, - rawResponse: $uploadUrl, + rawResponse: $response->body(), ); } } /** - * With `file_url` Meta fetches the video from our CDN asynchronously, so - * the rupload POST returns before the bytes exist on their side. Calling - * `finish` on that empty session is what produced error 6000; wait until - * the uploading phase reports complete. + * With `file_url` Meta fetches the video asynchronously, so the rupload POST + * returns before the bytes exist on their side. Calling `finish` on that + * empty session is what produced error 6000; wait until the uploading phase + * reports complete. */ private function waitForStoryUpload(string $videoId, string $accessToken): void { for ($attempt = 0; $attempt < self::STORY_UPLOAD_MAX_POLLS; $attempt++) { - $statusResponse = $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ + $response = $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ 'fields' => 'status', 'access_token' => $accessToken, ]); - if ($statusResponse->failed()) { - $this->handleApiError($statusResponse); + if ($response->failed()) { + $this->handleApiError($response); } - $status = data_get($statusResponse->json(), 'status', []); - $videoStatus = data_get($status, 'video_status'); - $uploadingStatus = data_get($status, 'uploading_phase.status'); - $detail = data_get($status, 'processing_phase.error.message') - ?? data_get($status, 'uploading_phase.error.message'); + $status = data_get($response->json(), 'status', []); + $failure = $this->storyUploadFailure($status); - if ($detail !== null - || in_array($videoStatus, ['error', 'expired'], true) - || $uploadingStatus === 'error' - || data_get($status, 'processing_phase.status') === 'error') { + if ($failure !== null) { throw new FacebookPublishException( - userMessage: is_string($detail) && $detail !== '' - ? $detail - : 'Facebook could not process the story video. Please try another file.', + userMessage: $failure, category: ErrorCategory::MediaFormat, - rawResponse: $statusResponse->body(), + rawResponse: $response->body(), ); } - if ($uploadingStatus === 'complete' || in_array($videoStatus, ['ready', 'upload_complete'], true)) { + if ($this->storyUploadComplete($status)) { return; } @@ -510,6 +422,124 @@ private function waitForStoryUpload(string $videoId, string $accessToken): void ); } + /** + * 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 storyUploadFailure(array $status): ?string + { + $detail = data_get($status, 'processing_phase.error.message') + ?? data_get($status, 'uploading_phase.error.message'); + + if (is_string($detail) && $detail !== '') { + return $detail; + } + + $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 $failed ? 'Facebook could not process the story video. Please try another file.' : null; + } + + /** + * @param array $status + */ + private function storyUploadComplete(array $status): bool + { + return data_get($status, 'uploading_phase.status') === 'complete' + || in_array(data_get($status, 'video_status'), ['ready', 'upload_complete'], true); + } + + private function requireVideo(?MediaItem $media, string $format): MediaItem + { + if ($media === null || ! $media->isVideo()) { + throw new FacebookPublishException( + userMessage: "Facebook {$format} require a video file.", + category: ErrorCategory::MediaFormat, + ); + } + + return $media; + } + + private function sanitizedContent(PostPlatform $postPlatform): ?string + { + $content = $postPlatform->post->content; + + return filled($content) + ? app(ContentSanitizer::class)->sanitize($content, $postPlatform->platform) + : null; + } + + /** + * 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(); + } + + /** + * 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->facebookHttp()->post("{$this->baseUrl}/{$path}", $payload); + + if ($response->failed()) { + Log::error("Facebook {$label} failed", [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + return $response; + } + + /** + * @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' => $postId, + 'url' => "https://www.facebook.com/{$postId}", + ]; + } + + private function videoPreparationException(): FacebookPublishException + { + return new FacebookPublishException( + userMessage: 'Could not prepare the Facebook video for upload.', + category: ErrorCategory::ServerError, + ); + } + private function handleApiError(Response $response): never { throw FacebookPublishException::fromApiResponse($response); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 0e482196c..3675c11b0 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -320,7 +320,7 @@ function facebookStoryFakes(): array expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow( FacebookPublishException::class, - 'Facebook did not return upload_url for reel start.' + 'Facebook did not start the video upload. Please try again.' ); }); @@ -376,6 +376,67 @@ function facebookStoryFakes(): array ->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 rejects a reel upload_url outside the rupload host', 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::response([ + 'video_id' => 'reel_video_123', + 'upload_url' => 'https://evil.example/steal-token', + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + + Http::assertSentCount(1); +}); + test('facebook publisher can publish video story', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); $this->post->update(['media' => facebookStoryVideoMedia()]); @@ -444,7 +505,7 @@ function facebookStoryFakes(): array ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook did not start the story upload. Please try again.'); + ->toThrow(FacebookPublishException::class, 'Facebook did not start the video upload. Please try again.'); Http::assertSentCount(1); }); From 64524abe1d8cc3b7cbf652acebb5670f644438ed Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 09:41:25 -0300 Subject: [PATCH 03/15] Keep polling the story upload through transient Graph errors A 5xx or rate limit on the status check aborted the publish after rupload had already accepted the video, orphaning the session and forcing a full re-upload on retry. Transient failures now log and wait for the next poll; confirmed rejections still fail immediately. Also restore the ??-fallback for the finish ids so a null id falls back to video_id as before. --- app/Services/Social/FacebookPublisher.php | 23 +++++++--- .../Services/Social/FacebookPublisherTest.php | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index f06155dba..b541216a5 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -13,6 +13,7 @@ use App\Models\PostPlatform; use App\Services\Social\Concerns\CropsImageForAspectRatio; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Services\Social\Meta\GraphError; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Collection; @@ -215,7 +216,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte ...$this->optionalField('description', $content), ], 'reel finish'); - $reelId = data_get($response->json(), 'id', $videoId); + $reelId = data_get($response->json(), 'id') ?? $videoId; return [ 'id' => $reelId, @@ -239,7 +240,7 @@ private function publishStory(string $pageId, string $accessToken, MediaItem $me 'access_token' => $accessToken, ], 'story finish'); - $storyId = data_get($response->json(), 'post_id', $videoId); + $storyId = data_get($response->json(), 'post_id') ?? $videoId; return [ 'id' => $storyId, @@ -260,8 +261,9 @@ private function startVideoUpload(string $pageId, string $accessToken, string $e 'access_token' => $accessToken, ], "{$edge} start"); - $videoId = data_get($response->json(), 'video_id'); - $uploadUrl = data_get($response->json(), 'upload_url'); + $data = $response->json(); + $videoId = data_get($data, 'video_id'); + $uploadUrl = data_get($data, 'upload_url'); if (! filled($videoId) || ! is_string($uploadUrl) || ! filled($uploadUrl)) { throw new FacebookPublishException( @@ -395,7 +397,18 @@ private function waitForStoryUpload(string $videoId, string $accessToken): void ]); if ($response->failed()) { - $this->handleApiError($response); + if (! GraphError::isTransientFailure($response)) { + $this->handleApiError($response); + } + + Log::warning('Facebook story status check failed transiently', [ + 'video_id' => $videoId, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + Sleep::for(self::STORY_UPLOAD_POLL_SECONDS)->seconds(); + + continue; } $status = data_get($response->json(), 'status', []); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 3675c11b0..6db07f11d 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -496,6 +496,48 @@ function facebookStoryFakes(): array ]); }); +test('facebook publisher keeps polling the story status through a transient graph error', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookStoryFakes(), + "{$graph}/story_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), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('video_story_post_123'); + + Sleep::assertSleptTimes(1); +}); + +test('facebook publisher stops polling the story status on a confirmed graph rejection', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookStoryFakes(), + "{$graph}/story_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/video_stories') + && $request['upload_phase'] === 'finish'); +}); + test('facebook publisher fails story publish when start does not return upload_url', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); $this->post->update(['media' => facebookStoryVideoMedia()]); From 52ba1631bb6c08ac141135016de34174e26cfb9e Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 10:05:47 -0300 Subject: [PATCH 04/15] Reschedule Facebook publishes when the API cannot be reached A connection that never completes (DNS, TCP or TLS timeout) surfaced as an unexpected error and marked the post failed, even though Meta had received nothing. Every Graph, rupload and media-download call now maps ConnectionException to PlatformUnavailableException so the job retries in 60 seconds, matching the Instagram publisher. --- app/Services/Social/FacebookPublisher.php | 99 +++++++++++++------ .../Services/Social/FacebookPublisherTest.php | 47 +++++++++ 2 files changed, 118 insertions(+), 28 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b541216a5..ab006bd3c 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -7,6 +7,7 @@ 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; @@ -14,6 +15,8 @@ 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\Collection; @@ -32,6 +35,8 @@ class FacebookPublisher private const int STORY_UPLOAD_MAX_POLLS = 60; + private const int UNREACHABLE_RETRY_DELAY_SECONDS = 60; + private string $baseUrl; public function __construct() @@ -160,12 +165,15 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str private function uploadUnpublishedPhoto(string $pageId, string $accessToken, MediaItem $media, ?string $aspectRatio): ?string { - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), - 'published' => 'false', - 'access_token' => $accessToken, - ...$this->altText($media), - ]); + $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', + ); if ($response->failed()) { Log::error('Facebook image upload failed', [ @@ -309,9 +317,12 @@ private function uploadVideoBytes(string $uploadUrl, string $accessToken, MediaI } try { - $download = Http::withOptions(['sink' => $tempFile]) - ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) - ->get($media->url); + $download = $this->reachOrRetry( + fn (): Response => Http::withOptions(['sink' => $tempFile]) + ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) + ->get($media->url), + 'media download', + ); if ($download->failed()) { throw new FacebookPublishException( @@ -329,14 +340,17 @@ private function uploadVideoBytes(string $uploadUrl, string $accessToken, MediaI } try { - $uploadResponse = Http::withHeaders([ - 'Authorization' => "OAuth {$accessToken}", - 'Offset' => '0', - 'file_size' => (string) $fileSize, - ]) - ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) - ->withBody($stream, $media->mime_type ?? 'video/mp4') - ->post($uploadUrl); + $uploadResponse = $this->reachOrRetry( + fn (): Response => Http::withHeaders([ + 'Authorization' => "OAuth {$accessToken}", + 'Offset' => '0', + 'file_size' => (string) $fileSize, + ]) + ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) + ->withBody($stream, $media->mime_type ?? 'video/mp4') + ->post($uploadUrl), + 'reel upload', + ); } finally { if (is_resource($stream)) { fclose($stream); @@ -359,12 +373,15 @@ private function uploadVideoBytes(string $uploadUrl, string $accessToken, MediaI */ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, MediaItem $media): void { - $response = $this->socialHttp() - ->withHeaders([ - 'Authorization' => "OAuth {$accessToken}", - 'file_url' => $media->url, - ]) - ->send('POST', $uploadUrl); + $response = $this->reachOrRetry( + fn (): Response => $this->socialHttp() + ->withHeaders([ + 'Authorization' => "OAuth {$accessToken}", + 'file_url' => $media->url, + ]) + ->send('POST', $uploadUrl), + 'story upload', + ); if ($response->failed()) { Log::error('Facebook video story upload failed', [ @@ -391,10 +408,13 @@ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, Medi private function waitForStoryUpload(string $videoId, string $accessToken): void { for ($attempt = 0; $attempt < self::STORY_UPLOAD_MAX_POLLS; $attempt++) { - $response = $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ - 'fields' => 'status', - 'access_token' => $accessToken, - ]); + $response = $this->reachOrRetry( + fn (): Response => $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ + 'fields' => 'status', + 'access_token' => $accessToken, + ]), + 'story status', + ); if ($response->failed()) { if (! GraphError::isTransientFailure($response)) { @@ -505,7 +525,10 @@ private function facebookHttp(): PendingRequest */ private function postToGraph(string $path, array $payload, string $label): Response { - $response = $this->facebookHttp()->post("{$this->baseUrl}/{$path}", $payload); + $response = $this->reachOrRetry( + fn (): Response => $this->facebookHttp()->post("{$this->baseUrl}/{$path}", $payload), + $label, + ); if ($response->failed()) { Log::error("Facebook {$label} failed", [ @@ -518,6 +541,26 @@ private function postToGraph(string $path, array $payload, string $label): Respo return $response; } + /** + * 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. + * + * @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: {$exception->getMessage()}", + retryDelaySeconds: self::UNREACHABLE_RETRY_DELAY_SECONDS, + ); + } + } + /** * @return array */ diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 6db07f11d..ab250f70a 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,6 +13,7 @@ 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; @@ -496,6 +498,51 @@ function facebookStoryFakes(): array ]); }); +test('facebook publisher reschedules the story when rupload cannot be reached', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + + Http::fake([ + ...facebookStoryFakes(), + "{$rupload}/*" => fn () => throw new ConnectionException('cURL error 28: Connection timed out after 10003 milliseconds'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->retryDelaySeconds)->toBe(60) + ->and($exception->getMessage())->toContain('story upload unreachable'); + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + && $request['upload_phase'] === 'finish'); +}); + +test('facebook publisher reschedules the story when the status check cannot be reached', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); + $this->post->update(['media' => facebookStoryVideoMedia()]); + + $graph = config('trypost.platforms.facebook.graph_api'); + + Http::fake([ + ...facebookStoryFakes(), + "{$graph}/story_video_123?fields=status*" => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(PlatformUnavailableException::class); +}); + +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 keeps polling the story status through a transient graph error', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); $this->post->update(['media' => facebookStoryVideoMedia()]); From f2c30425837230a9f61ff95e0582e4d432fd843d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 10:05:47 -0300 Subject: [PATCH 05/15] Read Facebook post metrics per content type Stories and Reels are different Graph nodes from feed posts: a Story only answers the story metric family and a Reel exposes video_insights, so asking either for post_impressions was a #100 rejection and the post page showed nothing. Pick edge and metrics by content type, label them through analytics.metrics.*, and drop the post_impressions family, which Meta deprecates above Graph API v25, in favour of the media_view metrics. Adds analytics.metrics.reactions to all locales. --- app/Services/Social/FacebookAnalytics.php | 71 +++++++- lang/ar/analytics.php | 1 + lang/de/analytics.php | 1 + lang/el/analytics.php | 1 + lang/en/analytics.php | 1 + lang/es/analytics.php | 1 + lang/fr/analytics.php | 1 + lang/it/analytics.php | 1 + lang/ja/analytics.php | 1 + lang/ko/analytics.php | 1 + lang/nl/analytics.php | 1 + lang/pl/analytics.php | 1 + lang/pt-BR/analytics.php | 1 + lang/ru/analytics.php | 1 + lang/tr/analytics.php | 1 + lang/uk/analytics.php | 1 + lang/zh/analytics.php | 1 + .../Services/Social/FacebookAnalyticsTest.php | 159 ++++++++++++++++++ 18 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Services/Social/FacebookAnalyticsTest.php diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php index 79d0ad84b..41cb0fc83 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,82 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } + [$edge, $metrics] = $this->postMetricsFor($postPlatform->content_type); + $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)), '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', [])) + ->map(fn (array $item): array => [ + 'label' => __($metrics[data_get($item, 'name')] ?? 'analytics.metrics.'.data_get($item, 'name', '')), + 'value' => $this->metricValue(data_get($item, 'values.0.value')), ]) ->values() ->all(); } + /** + * Each Facebook publish type stores a different kind of Graph node, and each + * node exposes its own insights: a feed post has `/insights` with `post_*` + * metrics, a Reel is a bare video whose numbers live on `/video_insights`, + * and a Story only answers to the `story` metric family. Asking a Story or + * a Reel for `post_impressions` 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 instead. + * + * @return array{0: string, 1: array} + */ + private function postMetricsFor(?ContentType $contentType): array + { + return match ($contentType) { + ContentType::FacebookReel => ['video_insights', [ + 'total_video_impressions' => 'analytics.metrics.impressions', + 'total_video_views' => 'analytics.metrics.video_views', + 'total_video_reactions_by_type_total' => 'analytics.metrics.reactions', + ]], + ContentType::FacebookStory => ['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', + ]], + default => ['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', + ]], + }; + } + + /** + * Most metrics are a plain count; the `*_by_type_total` family returns one + * count per reaction type and is reported as their sum. + */ + 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/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/tests/Feature/Services/Social/FacebookAnalyticsTest.php b/tests/Feature/Services/Social/FacebookAnalyticsTest.php new file mode 100644 index 000000000..0ee5b63c6 --- /dev/null +++ b/tests/Feature/Services/Social/FacebookAnalyticsTest.php @@ -0,0 +1,159 @@ +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'); +}); + +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 reel metrics from the video insights edge', function () { + Http::fake([ + "{$this->graph}/reel_video_123/video_insights*" => Http::response(facebookInsightsResponse([ + ['name' => 'total_video_impressions', 'value' => 500], + ['name' => 'total_video_views', 'value' => 210], + ['name' => 'total_video_reactions_by_type_total', 'value' => ['like' => 4, 'love' => 2, 'haha' => 1]], + ])), + ]); + + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform(ContentType::FacebookReel, 'reel_video_123')); + + expect($metrics)->toBe([ + ['label' => 'Impressions', 'value' => 500], + ['label' => 'Video Views', 'value' => 210], + ['label' => 'Reactions', 'value' => 7], + ]); + + Http::assertNotSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/reel_video_123/insights")); +}); + +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 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(); +}); From fa2ba6297f643ac295ff42c36b2efc25af7a1cb6 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 10:17:31 -0300 Subject: [PATCH 06/15] Show the content type next to each platform on the post page A Facebook card read the same for a Reel, a Story and a feed post. Tag the format where it was a choice (Facebook, Instagram, Pinterest) using the existing posts.content_types labels; single-format platforms stay as they are. --- resources/js/pages/posts/Show.vue | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/posts/Show.vue b/resources/js/pages/posts/Show.vue index a1d15d559..b779a87e2 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 { getContentTypeOptions, getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/usePostStatus'; import date from '@/date'; import AppLayout from '@/layouts/AppLayout.vue'; @@ -83,6 +83,16 @@ const getDisplayUsername = (pp: PostPlatform): string | null => pp.display_usern const getDisplayAvatar = (pp: PostPlatform): string | null => pp.display_avatar; +/** + * The format tag only earns its place where the format was a choice: a + * Facebook Reel vs Story vs Post, an Instagram Feed vs Reel. Platforms with a + * single content type would just repeat the platform name. + */ +const getContentTypeLabelKey = (pp: PostPlatform): string | null => + pp.content_type && getContentTypeOptions(pp.platform).length > 1 + ? `posts.content_types.${pp.content_type}.label` + : null; + const formatDateTime = (value: string | null): string => value ? date.formatDateTime(value) : ''; @@ -239,7 +249,16 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {
-

{{ getDisplayName(pp) }}

+
+

{{ getDisplayName(pp) }}

+ + {{ $t(getContentTypeLabelKey(pp)!) }} + +

@{{ getDisplayUsername(pp) }} · {{ getPlatformLabel(pp.platform) }} From 8deb1903e7aa6d30e8c90ee2cc27e1ab5038cc9f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 10:22:29 -0300 Subject: [PATCH 07/15] Move the content type tag rule into usePlatformLogo hasContentTypeChoice() names the 'did the user pick a format here' check next to the platform/content-type map that answers it, and the i18n key is built in one place shared with getContentTypeOptions(). The post page resolves the key once per platform instead of calling a helper twice in the template. --- resources/js/composables/usePlatformLogo.ts | 15 +++++++++++++- resources/js/pages/posts/Show.vue | 22 ++++++++------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index 9863ce75d..46a2cc30b 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -86,8 +86,21 @@ export const getPlatformTheme = (platform: string): { bg: string; rotate: string export const getPlatformLabel = (platform: string): string => PLATFORM_LABELS[platform] ?? platform; +const contentTypeLabelKey = (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: contentTypeLabelKey(value), })); + +/** Whether the user picks a format on this platform, or it only has one. */ +export const hasContentTypeChoice = (platform: string): boolean => + getContentTypeOptions(platform).length > 1; + +/** + * Label key for a published format, or null when it was never a choice: + * tagging "Post" on X would just repeat the platform name. + */ +export const getContentTypeLabelKey = (platform: string, contentType: string | null): string | null => + contentType && hasContentTypeChoice(platform) ? contentTypeLabelKey(contentType) : null; diff --git a/resources/js/pages/posts/Show.vue b/resources/js/pages/posts/Show.vue index b779a87e2..2c7d585b7 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 { getContentTypeOptions, getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo'; +import { getContentTypeLabelKey, 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, contentTypeLabelKey: getContentTypeLabelKey(pp.platform, pp.content_type) })), +); const isPublishing = computed(() => props.post.status === PostStatus.Publishing); @@ -83,16 +87,6 @@ const getDisplayUsername = (pp: PostPlatform): string | null => pp.display_usern const getDisplayAvatar = (pp: PostPlatform): string | null => pp.display_avatar; -/** - * The format tag only earns its place where the format was a choice: a - * Facebook Reel vs Story vs Post, an Instagram Feed vs Reel. Platforms with a - * single content type would just repeat the platform name. - */ -const getContentTypeLabelKey = (pp: PostPlatform): string | null => - pp.content_type && getContentTypeOptions(pp.platform).length > 1 - ? `posts.content_types.${pp.content_type}.label` - : null; - const formatDateTime = (value: string | null): string => value ? date.formatDateTime(value) : ''; @@ -252,11 +246,11 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {

{{ getDisplayName(pp) }}

- {{ $t(getContentTypeLabelKey(pp)!) }} + {{ $t(pp.contentTypeLabelKey) }}

From f870f664f697c2682e744aa3a58e9e35ff57ea0a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 10:24:56 -0300 Subject: [PATCH 08/15] Name the content type helpers after the question each answers translationKeyFor builds the i18n key, hasMultipleContentTypes asks whether the platform offers a choice, and getContentTypeBadgeKey resolves what the badge shows. The three previous names differed by one word. --- resources/js/composables/usePlatformLogo.ts | 15 ++++++++------- resources/js/pages/posts/Show.vue | 8 ++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index 46a2cc30b..037be8aa6 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -86,21 +86,22 @@ export const getPlatformTheme = (platform: string): { bg: string; rotate: string export const getPlatformLabel = (platform: string): string => PLATFORM_LABELS[platform] ?? platform; -const contentTypeLabelKey = (contentType: string): string => `posts.content_types.${contentType}.label`; +const translationKeyFor = (contentType: string): string => `posts.content_types.${contentType}.label`; export const getContentTypeOptions = (platform: string): ContentTypeOption[] => (PLATFORM_CONTENT_TYPES[platform] ?? []).map((value) => ({ value, - labelKey: contentTypeLabelKey(value), + labelKey: translationKeyFor(value), })); /** Whether the user picks a format on this platform, or it only has one. */ -export const hasContentTypeChoice = (platform: string): boolean => +export const hasMultipleContentTypes = (platform: string): boolean => getContentTypeOptions(platform).length > 1; /** - * Label key for a published format, or null when it was never a choice: - * tagging "Post" on X would just repeat the platform name. + * 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 getContentTypeLabelKey = (platform: string, contentType: string | null): string | null => - contentType && hasContentTypeChoice(platform) ? contentTypeLabelKey(contentType) : null; +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 2c7d585b7..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 { getContentTypeLabelKey, 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'; @@ -69,7 +69,7 @@ const props = defineProps<{ const enabledPlatforms = computed(() => props.post.platforms .filter((pp) => pp.enabled) - .map((pp) => ({ ...pp, contentTypeLabelKey: getContentTypeLabelKey(pp.platform, pp.content_type) })), + .map((pp) => ({ ...pp, contentTypeBadgeKey: getContentTypeBadgeKey(pp.platform, pp.content_type) })), ); const isPublishing = computed(() => props.post.status === PostStatus.Publishing); @@ -246,11 +246,11 @@ usePostEcho(props.post.id, '.post.platform.status.updated', () => {

{{ getDisplayName(pp) }}

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

From 9d5514bfe3bbfbc0d7e2031e4a55c4be0acd870b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 10:43:26 -0300 Subject: [PATCH 09/15] Cover the Facebook publisher error paths that had no tests Partial carousel upload, whitespace-only text, reel rupload rejection, empty reel download and an unreachable CDN each get a test, all checking that finish is never sent and the reel temp file is gone. An empty download now says so instead of reporting a preparation failure. --- app/Services/Social/FacebookPublisher.php | 10 +- .../Services/Social/FacebookPublisherTest.php | 126 +++++++++++++++++- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index ab006bd3c..b92b15219 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -333,7 +333,15 @@ private function uploadVideoBytes(string $uploadUrl, string $accessToken, MediaI } $fileSize = filesize($tempFile); - $stream = $fileSize !== false && $fileSize > 0 ? fopen($tempFile, 'rb') : false; + + if ($fileSize === false || $fileSize < 1) { + throw new FacebookPublishException( + userMessage: 'The downloaded Facebook video is empty.', + category: ErrorCategory::MediaFormat, + ); + } + + $stream = fopen($tempFile, 'rb'); if ($stream === false) { throw $this->videoPreparationException(); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index ab250f70a..8e87bd6e7 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -44,6 +44,22 @@ function facebookStoryVideoMedia(): array ]; } +/** + * @return array> + */ +function facebookReelVideoMedia(): array +{ + return [ + [ + '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', + ], + ]; +} + /** * Points the post at a single hosted story video and returns the fakes for the * happy path: start hands back the rupload URL, rupload accepts, the status @@ -777,6 +793,48 @@ function facebookStoryFakes(): array ->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' => [ @@ -831,11 +889,71 @@ function facebookStoryFakes(): array $this->publisher->publish($this->postPlatform); - // Assert no leftover fb_reel_ temp files exist - $tempDir = sys_get_temp_dir(); - $leftoverFiles = glob("{$tempDir}/fb_reel_*") ?: []; + expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); +}); + +test('facebook publisher maps a reel rupload rejection and does not finish', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + $this->post->update(['media' => facebookReelVideoMedia()]); + + 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('fake-video', 200), + '*rupload.facebook.com/*' => Http::response([ + 'error' => ['message' => 'Problem with file', 'code' => 6000], + ], 400), + ]); + + 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/video_reels') + && $request['upload_phase'] === 'finish'); + + expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); +}); + +test('facebook publisher fails the reel when the downloaded video is empty', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + $this->post->update(['media' => facebookReelVideoMedia()]); + + 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('', 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'The downloaded Facebook video is empty.'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'rupload.facebook.com')); + + expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); +}); + +test('facebook publisher reschedules the reel when the media download cannot be reached', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + $this->post->update(['media' => facebookReelVideoMedia()]); + + 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/*' => fn () => throw new ConnectionException('cURL error 28: Connection timed out'), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(PlatformUnavailableException::class); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'rupload.facebook.com')); - expect($leftoverFiles)->toBeEmpty(); + expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); }); test('facebook publisher can publish single image with null content', function () { From 6db4a29c4dab22a13ec1f5219e6ca2c8009da3af Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 11:06:24 -0300 Subject: [PATCH 10/15] Publish Facebook Reels through the hosted-file upload like Stories The Reels Publishing API accepts the same file_url header on the rupload URL that Stories use, so the worker no longer downloads the video, opens a temp file and streams the bytes with Offset/file_size. Reels and Stories now share uploadVideo(): start, hand Meta the CDN URL, poll the status until the fetch completes, finish. The old byte upload existed because file_url had once been sent in the body instead of as a header. The shared flow's tests run against both formats through a dataset. --- app/Services/Social/FacebookPublisher.php | 156 ++--- .../Services/Social/FacebookPublisherTest.php | 570 ++++++------------ 2 files changed, 217 insertions(+), 509 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index b92b15219..ef2ad95e6 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -20,7 +20,6 @@ use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; @@ -29,11 +28,9 @@ class FacebookPublisher use CropsImageForAspectRatio; use HasSocialHttpClient; - private const int VIDEO_TRANSFER_TIMEOUT_SECONDS = 600; + private const int VIDEO_UPLOAD_POLL_SECONDS = 5; - private const int STORY_UPLOAD_POLL_SECONDS = 5; - - private const int STORY_UPLOAD_MAX_POLLS = 60; + private const int VIDEO_UPLOAD_MAX_POLLS = 60; private const int UNREACHABLE_RETRY_DELAY_SECONDS = 60; @@ -212,9 +209,7 @@ private function publishVideoPost(string $pageId, string $accessToken, ?string $ */ private function publishReel(string $pageId, string $accessToken, ?string $content, MediaItem $media): array { - [$videoId, $uploadUrl] = $this->startVideoUpload($pageId, $accessToken, 'video_reels'); - - $this->uploadVideoBytes($uploadUrl, $accessToken, $media); + $videoId = $this->uploadVideo($pageId, $accessToken, 'video_reels', $media); $response = $this->postToGraph("{$pageId}/video_reels", [ 'upload_phase' => 'finish', @@ -237,10 +232,7 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte */ private function publishStory(string $pageId, string $accessToken, MediaItem $media): array { - [$videoId, $uploadUrl] = $this->startVideoUpload($pageId, $accessToken, 'video_stories'); - - $this->uploadVideoFromUrl($uploadUrl, $accessToken, $media); - $this->waitForStoryUpload($videoId, $accessToken); + $videoId = $this->uploadVideo($pageId, $accessToken, 'video_stories', $media); $response = $this->postToGraph("{$pageId}/video_stories", [ 'upload_phase' => 'finish', @@ -257,9 +249,26 @@ private function publishStory(string $pageId, string $accessToken, MediaItem $me } /** - * Phase 1 of Meta's resumable video flow, shared by Reels and Stories: the - * Graph edge opens a session and returns the rupload URL the file must go to. + * 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); + + $this->uploadVideoFromUrl($uploadUrl, $accessToken, $media); + $this->waitForVideoUpload($videoId, $accessToken); + + return $videoId; + } + + /** * @return array{0: string, 1: string} */ private function startVideoUpload(string $pageId, string $accessToken, string $edge): array @@ -304,80 +313,7 @@ private function assertRuploadUrl(string $uploadUrl): void } /** - * Reels still stream the file through the worker. The hosted `file_url` - * shortcut is only verified on Stories; the Reels session rejected it in - * the past ("Header Offset not convertable to unsigned long"). - */ - private function uploadVideoBytes(string $uploadUrl, string $accessToken, MediaItem $media): void - { - $tempFile = tempnam(sys_get_temp_dir(), 'fb_reel_'); - - if ($tempFile === false) { - throw $this->videoPreparationException(); - } - - try { - $download = $this->reachOrRetry( - fn (): Response => Http::withOptions(['sink' => $tempFile]) - ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) - ->get($media->url), - 'media download', - ); - - if ($download->failed()) { - throw new FacebookPublishException( - userMessage: 'Could not download media for Facebook reel.', - category: ErrorCategory::ServerError, - platformErrorCode: (string) $download->status(), - ); - } - - $fileSize = filesize($tempFile); - - if ($fileSize === false || $fileSize < 1) { - throw new FacebookPublishException( - userMessage: 'The downloaded Facebook video is empty.', - category: ErrorCategory::MediaFormat, - ); - } - - $stream = fopen($tempFile, 'rb'); - - if ($stream === false) { - throw $this->videoPreparationException(); - } - - try { - $uploadResponse = $this->reachOrRetry( - fn (): Response => Http::withHeaders([ - 'Authorization' => "OAuth {$accessToken}", - 'Offset' => '0', - 'file_size' => (string) $fileSize, - ]) - ->timeout(self::VIDEO_TRANSFER_TIMEOUT_SECONDS) - ->withBody($stream, $media->mime_type ?? 'video/mp4') - ->post($uploadUrl), - 'reel upload', - ); - } finally { - if (is_resource($stream)) { - fclose($stream); - } - } - - if ($uploadResponse->failed()) { - $this->handleApiError($uploadResponse); - } - } finally { - if (file_exists($tempFile) && ! unlink($tempFile)) { - Log::warning('Facebook reel temp file cleanup failed', ['path' => $tempFile]); - } - } - } - - /** - * Stories hand Meta the CDN URL and let it fetch the file (Page Stories API - * hosted-file upload). The request is the two headers and no body. + * The request is the two headers and no body; Meta fetches `file_url`. */ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, MediaItem $media): void { @@ -388,11 +324,11 @@ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, Medi 'file_url' => $media->url, ]) ->send('POST', $uploadUrl), - 'story upload', + 'video upload', ); if ($response->failed()) { - Log::error('Facebook video story upload failed', [ + Log::error('Facebook video upload failed', [ 'body' => $this->redactResponseBody($response->body()), ]); $this->handleApiError($response); @@ -400,28 +336,22 @@ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, Medi if (data_get($response->json(), 'success') !== true) { throw new FacebookPublishException( - userMessage: 'Facebook did not accept the story video. Please try again.', + userMessage: 'Facebook did not accept the video. Please try again.', category: ErrorCategory::ServerError, rawResponse: $response->body(), ); } } - /** - * With `file_url` Meta fetches the video asynchronously, so the rupload POST - * returns before the bytes exist on their side. Calling `finish` on that - * empty session is what produced error 6000; wait until the uploading phase - * reports complete. - */ - private function waitForStoryUpload(string $videoId, string $accessToken): void + private function waitForVideoUpload(string $videoId, string $accessToken): void { - for ($attempt = 0; $attempt < self::STORY_UPLOAD_MAX_POLLS; $attempt++) { + for ($attempt = 0; $attempt < self::VIDEO_UPLOAD_MAX_POLLS; $attempt++) { $response = $this->reachOrRetry( fn (): Response => $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ 'fields' => 'status', 'access_token' => $accessToken, ]), - 'story status', + 'video status', ); if ($response->failed()) { @@ -429,18 +359,18 @@ private function waitForStoryUpload(string $videoId, string $accessToken): void $this->handleApiError($response); } - Log::warning('Facebook story status check failed transiently', [ + Log::warning('Facebook video status check failed transiently', [ 'video_id' => $videoId, 'status' => $response->status(), 'body' => $this->redactResponseBody($response->body()), ]); - Sleep::for(self::STORY_UPLOAD_POLL_SECONDS)->seconds(); + Sleep::for(self::VIDEO_UPLOAD_POLL_SECONDS)->seconds(); continue; } $status = data_get($response->json(), 'status', []); - $failure = $this->storyUploadFailure($status); + $failure = $this->videoUploadFailure($status); if ($failure !== null) { throw new FacebookPublishException( @@ -450,15 +380,15 @@ private function waitForStoryUpload(string $videoId, string $accessToken): void ); } - if ($this->storyUploadComplete($status)) { + if ($this->videoUploadComplete($status)) { return; } - Sleep::for(self::STORY_UPLOAD_POLL_SECONDS)->seconds(); + Sleep::for(self::VIDEO_UPLOAD_POLL_SECONDS)->seconds(); } throw new FacebookPublishException( - userMessage: 'Facebook took too long to fetch the story video. Please try again.', + userMessage: 'Facebook took too long to fetch the video. Please try again.', category: ErrorCategory::ServerError, ); } @@ -470,7 +400,7 @@ private function waitForStoryUpload(string $videoId, string $accessToken): void * * @param array $status */ - private function storyUploadFailure(array $status): ?string + private function videoUploadFailure(array $status): ?string { $detail = data_get($status, 'processing_phase.error.message') ?? data_get($status, 'uploading_phase.error.message'); @@ -483,13 +413,13 @@ private function storyUploadFailure(array $status): ?string || data_get($status, 'uploading_phase.status') === 'error' || data_get($status, 'processing_phase.status') === 'error'; - return $failed ? 'Facebook could not process the story video. Please try another file.' : null; + return $failed ? 'Facebook could not process the video. Please try another file.' : null; } /** * @param array $status */ - private function storyUploadComplete(array $status): bool + 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); @@ -596,14 +526,6 @@ private function feedPostResult(mixed $postId): array ]; } - private function videoPreparationException(): FacebookPublishException - { - return new FacebookPublishException( - userMessage: 'Could not prepare the Facebook video for upload.', - category: ErrorCategory::ServerError, - ); - } - private function handleApiError(Response $response): never { throw FacebookPublishException::fromApiResponse($response); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 8e87bd6e7..ec60f50c1 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -31,61 +31,50 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string /** * @return array> */ -function facebookStoryVideoMedia(): array +function facebookVideoMedia(): array { return [ [ - 'id' => 'test-media-video-story', - 'path' => 'media/2026-01/story.mp4', - 'url' => 'https://example.com/media/2026-01/story.mp4', + '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' => 'story.mp4', + 'original_filename' => 'video.mp4', ], ]; } /** - * @return array> - */ -function facebookReelVideoMedia(): array -{ - return [ - [ - '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', - ], - ]; -} - -/** - * Points the post at a single hosted story video and returns the fakes for the - * happy path: start hands back the rupload URL, rupload accepts, the status - * poll reports the upload complete, finish publishes. + * 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 facebookStoryFakes(): array +function facebookVideoUploadFakes(string $edge): array { $graph = config('trypost.platforms.facebook.graph_api'); $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); return [ - '*/page_123/video_stories' => Http::sequence() + "*/page_123/{$edge}" => Http::sequence() ->push([ - 'video_id' => 'story_video_123', - 'upload_url' => "{$rupload}/video-upload/v25.0/story_video_123", + 'video_id' => 'video_123', + 'upload_url' => "{$rupload}/video-upload/v25.0/video_123", ], 200) - ->push(['success' => true, 'post_id' => 'video_story_post_123'], 200), + ->push(['success' => true, 'id' => 'reel_456', 'post_id' => 'story_456'], 200), "{$rupload}/*" => Http::response(['success' => true], 200), - "{$graph}/story_video_123?fields=status*" => Http::response([ + "{$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(); @@ -264,117 +253,6 @@ function facebookStoryFakes(): array }); }); -test('facebook publisher can publish reel', 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_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), - ]); - - $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'); - - // 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')) { - 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 '); - }); -}); - -test('facebook publisher fails reel publish when start does not return upload_url', 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', - ], - ], - ]); - - // Missing upload_url in the start response — should not silently - // proceed to a broken transfer (which is what the old code did). - Http::fake([ - '*/page_123/video_reels' => Http::response([ - 'video_id' => 'reel_video_123', - ], 200), - ]); - - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow( - FacebookPublishException::class, - 'Facebook did not start the video upload. Please try again.' - ); -}); - -test('facebook publisher fails reel publish with typed exception when media download fails', 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', - ], - ], - ]); - - // 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. - 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), - ]); - - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow( - FacebookPublishException::class, - 'Could not download media for Facebook reel.' - ); -}); - test('facebook publisher rejects image story', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); @@ -427,44 +305,48 @@ function facebookStoryFakes(): array Http::assertNothingSent(); }); -test('facebook publisher rejects a reel upload_url outside the rupload host', function () { +test('facebook publisher can publish reel', function () { $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + $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', - ], - ], - ]); + Http::fake(facebookVideoUploadFakes('video_reels')); - Http::fake([ - '*/page_123/video_reels' => Http::response([ - 'video_id' => 'reel_video_123', - 'upload_url' => 'https://evil.example/steal-token', - ], 200), - ]); + $result = $this->publisher->publish($this->postPlatform); - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + expect($result['id'])->toBe('reel_456'); + expect($result['url'])->toBe('https://www.facebook.com/reel/reel_456'); - Http::assertSentCount(1); + 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' => facebookStoryVideoMedia()]); + $this->post->update(['media' => facebookVideoMedia()]); - Http::fake(facebookStoryFakes()); + Http::fake(facebookVideoUploadFakes('video_stories')); $result = $this->publisher->publish($this->postPlatform); - expect($result['id'])->toBe('video_story_post_123'); - expect($result['url'])->toBe('https://www.facebook.com/stories/page_123/video_story_post_123'); + 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); Http::assertSent(function ($request) { if (! str_contains($request->url(), config('trypost.platforms.facebook.rupload_host'))) { @@ -472,7 +354,7 @@ function facebookStoryFakes(): array } return $request->method() === 'POST' - && ($request->header('file_url')[0] ?? null) === 'https://example.com/media/2026-01/story.mp4' + && ($request->header('file_url')[0] ?? null) === 'https://example.com/media/2026-01/video.mp4' && str_starts_with($request->header('Authorization')[0] ?? '', 'OAuth ') && $request->body() === ''; }); @@ -480,185 +362,188 @@ function facebookStoryFakes(): array Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.com/media')); Http::assertNotSent(fn ($request) => $request->method() === 'POST' - && str_contains($request->url(), config('trypost.platforms.facebook.graph_api').'/story_video_123')); - - Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') - && $request['upload_phase'] === 'finish' - && $request['video_id'] === 'story_video_123'); + && str_contains($request->url(), config('trypost.platforms.facebook.graph_api').'/video_123')); Sleep::assertNeverSlept(); -}); -test('facebook publisher waits for the story upload before finishing', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); + 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([ - ...facebookStoryFakes(), - "{$graph}/story_video_123?fields=status*" => Http::sequence() + ...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), ]); - $result = $this->publisher->publish($this->postPlatform); - - expect($result['id'])->toBe('video_story_post_123'); + $this->publisher->publish($this->postPlatform); Sleep::assertSleptTimes(2); Sleep::assertSequence([ Sleep::for(5)->seconds(), Sleep::for(5)->seconds(), ]); -}); - -test('facebook publisher reschedules the story when rupload cannot be reached', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +})->with('facebook resumable video formats'); - $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); +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([ - ...facebookStoryFakes(), - "{$rupload}/*" => fn () => throw new ConnectionException('cURL error 28: Connection timed out after 10003 milliseconds'), + "*/page_123/{$edge}" => Http::response(['video_id' => 'video_123'], 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(function (PlatformUnavailableException $exception): void { - expect($exception->retryDelaySeconds)->toBe(60) - ->and($exception->getMessage())->toContain('story upload unreachable'); - }); - - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') - && $request['upload_phase'] === 'finish'); -}); + ->toThrow(FacebookPublishException::class, 'Facebook did not start the video upload. Please try again.'); -test('facebook publisher reschedules the story when the status check cannot be reached', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); + Http::assertSentCount(1); +})->with('facebook resumable video formats'); - $graph = config('trypost.platforms.facebook.graph_api'); +test('facebook publisher rejects an upload_url outside the rupload host', function (ContentType $contentType, string $edge) { + $this->postPlatform->update(['content_type' => $contentType]); + $this->post->update(['media' => facebookVideoMedia()]); Http::fake([ - ...facebookStoryFakes(), - "{$graph}/story_video_123?fields=status*" => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + "*/page_123/{$edge}" => Http::response([ + 'video_id' => 'video_123', + 'upload_url' => 'https://evil.example/steal-token', + ], 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(PlatformUnavailableException::class); -}); + ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + + Http::assertSentCount(1); +})->with('facebook resumable video formats'); + +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()]); + + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); -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'), + ...facebookVideoUploadFakes($edge), + "{$rupload}/*" => Http::response(['error' => ['message' => 'Problem with file', 'code' => 6000]], 400), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(PlatformUnavailableException::class); -}); + ->toThrow(FacebookPublishException::class, 'Problem with file. Try with another file.'); -test('facebook publisher keeps polling the story status through a transient graph error', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); - $graph = config('trypost.platforms.facebook.graph_api'); +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([ - ...facebookStoryFakes(), - "{$graph}/story_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), + ...facebookVideoUploadFakes($edge), + "{$rupload}/*" => Http::response(['success' => false], 200), ]); - $result = $this->publisher->publish($this->postPlatform); - - expect($result['id'])->toBe('video_story_post_123'); + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(FacebookPublishException::class, 'Facebook did not accept the video. Please try again.'); - Sleep::assertSleptTimes(1); -}); + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") + && $request['upload_phase'] === 'finish'); +})->with('facebook resumable video formats'); -test('facebook publisher stops polling the story status on a confirmed graph rejection', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +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()]); - $graph = config('trypost.platforms.facebook.graph_api'); + $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); Http::fake([ - ...facebookStoryFakes(), - "{$graph}/story_video_123?fields=status*" => Http::response([ - 'error' => ['message' => 'Unsupported get request.', 'type' => 'GraphMethodException', 'code' => 100], - ], 400), + ...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, 'Unsupported get request.'); - - Sleep::assertNeverSlept(); + ->toThrow(function (PlatformUnavailableException $exception): void { + expect($exception->retryDelaySeconds)->toBe(60) + ->and($exception->getMessage())->toContain('video upload unreachable'); + }); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") && $request['upload_phase'] === 'finish'); -}); +})->with('facebook resumable video formats'); -test('facebook publisher fails story publish when start does not return upload_url', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +test('facebook publisher reschedules when the status check cannot be reached', 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::response(['video_id' => 'story_video_123'], 200), + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook did not start the video upload. Please try again.'); + ->toThrow(PlatformUnavailableException::class); +})->with('facebook resumable video formats'); - Http::assertSentCount(1); -}); +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()]); -test('facebook publisher rejects a story upload_url outside the rupload host', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); + $graph = config('trypost.platforms.facebook.graph_api'); Http::fake([ - '*/page_123/video_stories' => Http::response([ - 'video_id' => 'story_video_123', - 'upload_url' => 'https://evil.example/steal-token', - ], 200), + ...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), ]); - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); + $this->publisher->publish($this->postPlatform); - Http::assertSentCount(1); -}); + Sleep::assertSleptTimes(1); +})->with('facebook resumable video formats'); -test('facebook publisher does not finish the story when rupload does not confirm success', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +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()]); - $rupload = 'https://'.config('trypost.platforms.facebook.rupload_host'); + $graph = config('trypost.platforms.facebook.graph_api'); Http::fake([ - ...facebookStoryFakes(), - "{$rupload}/*" => Http::response(['success' => false], 200), + ...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, 'Facebook did not accept the story video. Please try again.'); + ->toThrow(FacebookPublishException::class, 'Unsupported get request.'); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + 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 story processing error instead of finishing', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +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([ - ...facebookStoryFakes(), - "{$graph}/story_video_123?fields=status*" => Http::response([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::response([ 'status' => [ 'video_status' => 'processing', 'uploading_phase' => ['status' => 'complete'], @@ -673,47 +558,54 @@ function facebookStoryFakes(): array 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/video_stories') + Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") && $request['upload_phase'] === 'finish'); -}); +})->with('facebook resumable video formats'); -test('facebook publisher fails the story when the upload session expires', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +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([ - ...facebookStoryFakes(), - "{$graph}/story_video_123?fields=status*" => Http::response([ - 'status' => ['video_status' => 'expired'], - ], 200), + ...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 story video. Please try another file.'); -}); + ->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 story upload that never completes', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookStory]); - $this->post->update(['media' => facebookStoryVideoMedia()]); +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([ - ...facebookStoryFakes(), - "{$graph}/story_video_123?fields=status*" => Http::response([ + ...facebookVideoUploadFakes($edge), + "{$graph}/video_123?fields=status*" => Http::response([ 'status' => ['video_status' => 'processing', 'uploading_phase' => ['status' => 'in_progress']], ], 200), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'Facebook took too long to fetch the story video. Please try again.'); + ->toThrow(FacebookPublishException::class, 'Facebook took too long to fetch the video. Please try again.'); Sleep::assertSleptTimes(60); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/page_123/video_stories') + 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 () { @@ -859,103 +751,6 @@ function facebookStoryFakes(): array ->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); - - expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); -}); - -test('facebook publisher maps a reel rupload rejection and does not finish', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); - $this->post->update(['media' => facebookReelVideoMedia()]); - - 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('fake-video', 200), - '*rupload.facebook.com/*' => Http::response([ - 'error' => ['message' => 'Problem with file', 'code' => 6000], - ], 400), - ]); - - 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/video_reels') - && $request['upload_phase'] === 'finish'); - - expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); -}); - -test('facebook publisher fails the reel when the downloaded video is empty', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); - $this->post->update(['media' => facebookReelVideoMedia()]); - - 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('', 200), - ]); - - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(FacebookPublishException::class, 'The downloaded Facebook video is empty.'); - - Http::assertNotSent(fn ($request) => str_contains($request->url(), 'rupload.facebook.com')); - - expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); -}); - -test('facebook publisher reschedules the reel when the media download cannot be reached', function () { - $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); - $this->post->update(['media' => facebookReelVideoMedia()]); - - 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/*' => fn () => throw new ConnectionException('cURL error 28: Connection timed out'), - ]); - - expect(fn () => $this->publisher->publish($this->postPlatform)) - ->toThrow(PlatformUnavailableException::class); - - Http::assertNotSent(fn ($request) => str_contains($request->url(), 'rupload.facebook.com')); - - expect(glob(sys_get_temp_dir().'/fb_reel_*') ?: [])->toBeEmpty(); -}); - test('facebook publisher can publish single image with null content', function () { $this->post->update([ 'content' => null, @@ -1079,16 +874,7 @@ function facebookStoryFakes(): array ], ]); - 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); From c115f6a1cd7f5266de26c832fbdf20f60313cd30 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 11:09:26 -0300 Subject: [PATCH 11/15] Redact the Page token from unreachable-host messages and tidy the poll cURL quotes the full URL in a timeout message, and the status poll carries access_token in its query string, so a connection failure there wrote the token into error_context and the logs. The message now goes through the redactor, with a test that forces the real URL into the exception. The poll sleeps once at the top of each retry instead of in two places, so a timeout no longer waits five seconds before giving up. A photo upload that returns no id is logged like a failed one, and the rupload host check is exercised with http, a userinfo trick and a non-URL as well. --- app/Services/Social/FacebookPublisher.php | 29 +++++++++++-------- .../Services/Social/FacebookPublisherTest.php | 24 ++++++++++----- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index ef2ad95e6..b9faf1839 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -123,7 +123,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st $data = $response->json(); - return $this->feedPostResult(data_get($data, 'post_id', data_get($data, 'id'))); + return $this->feedPostResult(data_get($data, 'post_id') ?? data_get($data, 'id')); } /** @@ -172,17 +172,18 @@ private function uploadUnpublishedPhoto(string $pageId, string $accessToken, Med 'image upload', ); - if ($response->failed()) { + $photoId = data_get($response->json(), 'id'); + + if ($response->failed() || ! is_string($photoId) || $photoId === '') { Log::error('Facebook image upload failed', [ + 'status' => $response->status(), 'body' => $this->redactResponseBody($response->body()), ]); return null; } - $photoId = data_get($response->json(), 'id'); - - return is_string($photoId) && $photoId !== '' ? $photoId : null; + return $photoId; } /** @@ -302,8 +303,9 @@ private function startVideoUpload(string $pageId, string $accessToken, string $e private function assertRuploadUrl(string $uploadUrl): void { $parts = parse_url($uploadUrl); + $allowedHost = config('trypost.platforms.facebook.rupload_host'); - if (data_get($parts, 'scheme') !== 'https' || data_get($parts, 'host') !== config('trypost.platforms.facebook.rupload_host')) { + if (data_get($parts, 'scheme') !== 'https' || data_get($parts, 'host') !== $allowedHost) { throw new FacebookPublishException( userMessage: 'Facebook returned an invalid upload URL.', category: ErrorCategory::ServerError, @@ -345,7 +347,11 @@ private function uploadVideoFromUrl(string $uploadUrl, string $accessToken, Medi private function waitForVideoUpload(string $videoId, string $accessToken): void { - for ($attempt = 0; $attempt < self::VIDEO_UPLOAD_MAX_POLLS; $attempt++) { + for ($attempt = 1; $attempt <= self::VIDEO_UPLOAD_MAX_POLLS; $attempt++) { + if ($attempt > 1) { + Sleep::for(self::VIDEO_UPLOAD_POLL_SECONDS)->seconds(); + } + $response = $this->reachOrRetry( fn (): Response => $this->socialHttp()->get("{$this->baseUrl}/{$videoId}", [ 'fields' => 'status', @@ -364,7 +370,6 @@ private function waitForVideoUpload(string $videoId, string $accessToken): void 'status' => $response->status(), 'body' => $this->redactResponseBody($response->body()), ]); - Sleep::for(self::VIDEO_UPLOAD_POLL_SECONDS)->seconds(); continue; } @@ -383,8 +388,6 @@ private function waitForVideoUpload(string $videoId, string $accessToken): void if ($this->videoUploadComplete($status)) { return; } - - Sleep::for(self::VIDEO_UPLOAD_POLL_SECONDS)->seconds(); } throw new FacebookPublishException( @@ -483,7 +486,9 @@ private function postToGraph(string $path, array $payload, string $label): Respo * 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. + * 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 */ @@ -493,7 +498,7 @@ private function reachOrRetry(Closure $request, string $label): Response return $request(); } catch (ConnectionException $exception) { throw new PlatformUnavailableException( - message: "Facebook {$label} unreachable: {$exception->getMessage()}", + message: "Facebook {$label} unreachable: ".$this->redactResponseBody($exception->getMessage()), retryDelaySeconds: self::UNREACHABLE_RETRY_DELAY_SECONDS, ); } diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index ec60f50c1..81e58d00d 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -406,14 +406,14 @@ function facebookVideoUploadFakes(string $edge): array Http::assertSentCount(1); })->with('facebook resumable video formats'); -test('facebook publisher rejects an upload_url outside the rupload host', function (ContentType $contentType, string $edge) { +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/{$edge}" => Http::response([ 'video_id' => 'video_123', - 'upload_url' => 'https://evil.example/steal-token', + 'upload_url' => $uploadUrl, ], 200), ]); @@ -421,7 +421,12 @@ function facebookVideoUploadFakes(string $edge): array ->toThrow(FacebookPublishException::class, 'Facebook returned an invalid upload URL.'); Http::assertSentCount(1); -})->with('facebook resumable video formats'); +})->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 maps a rupload rejection and does not finish', function (ContentType $contentType, string $edge) { $this->postPlatform->update(['content_type' => $contentType]); @@ -480,7 +485,7 @@ function facebookVideoUploadFakes(string $edge): array && $request['upload_phase'] === 'finish'); })->with('facebook resumable video formats'); -test('facebook publisher reschedules when the status check cannot be reached', function (ContentType $contentType, string $edge) { +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()]); @@ -488,11 +493,16 @@ function facebookVideoUploadFakes(string $edge): array Http::fake([ ...facebookVideoUploadFakes($edge), - "{$graph}/video_123?fields=status*" => fn () => throw new ConnectionException('cURL error 28: Operation timed out'), + "{$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(PlatformUnavailableException::class); + ->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) { @@ -593,7 +603,7 @@ function facebookVideoUploadFakes(string $edge): array expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(FacebookPublishException::class, 'Facebook took too long to fetch the video. Please try again.'); - Sleep::assertSleptTimes(60); + Sleep::assertSleptTimes(59); Http::assertNotSent(fn ($request) => str_contains($request->url(), "/page_123/{$edge}") && $request['upload_phase'] === 'finish'); From a20b4c84fff33d2fc73adf5d1a76a332ea49e666 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 11:13:54 -0300 Subject: [PATCH 12/15] Ignore unrequested Facebook metrics and cover the content type badge An insight we did not ask for used to render its raw translation key as the label; it is now dropped. The post page's content_type prop and the badge itself get a feature test and a browser test. --- app/Services/Social/FacebookAnalytics.php | 3 +- tests/Browser/PostShowContentTypeTest.php | 79 +++++++++++++++++++ tests/Feature/PostControllerTest.php | 27 +++++++ .../Services/Social/FacebookAnalyticsTest.php | 17 ++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/Browser/PostShowContentTypeTest.php diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php index 41cb0fc83..925273614 100644 --- a/app/Services/Social/FacebookAnalytics.php +++ b/app/Services/Social/FacebookAnalytics.php @@ -68,8 +68,9 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } 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')] ?? 'analytics.metrics.'.data_get($item, 'name', '')), + 'label' => __($metrics[data_get($item, 'name')]), 'value' => $this->metricValue(data_get($item, 'values.0.value')), ]) ->values() 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 index 0ee5b63c6..65cd9e0b5 100644 --- a/tests/Feature/Services/Social/FacebookAnalyticsTest.php +++ b/tests/Feature/Services/Social/FacebookAnalyticsTest.php @@ -130,6 +130,23 @@ function facebookPostPlatform(ContentType $contentType, string $platformPostId): && ! 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([ From f5fdb1d07bad538463712536a10bf6369b86b040 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 11:18:00 -0300 Subject: [PATCH 13/15] Prove the rupload host allowlist reads its config knob --- .../Services/Social/FacebookPublisherTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 81e58d00d..bfe06fe97 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -428,6 +428,24 @@ function facebookVideoUploadFakes(string $edge): array '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 maps a rupload rejection and does not finish', function (ContentType $contentType, string $edge) { $this->postPlatform->update(['content_type' => $contentType]); $this->post->update(['media' => facebookVideoMedia()]); From 02391067b5f0dbbfd5d69d138a5fac73884e0082 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 11:28:36 -0300 Subject: [PATCH 14/15] Pick Facebook post metrics by the shape of the stored id A timeline video is stored as the bare video id Meta returns from /videos, the same kind of node as a Reel, and a video node has no /insights edge at all. Choosing metrics by content type therefore sent timeline videos to a #100 rejection and the post page showed nothing. Feed posts are {page_id}_{post_id} and keep the post_* metrics; any bare id reads /video_insights. Reels also do not answer the documented total_video_* names, so the video set is the one Meta actually returns for short videos: fb_reels_total_plays, likes by reaction type and social actions, the last two summed from their per-type breakdown. Verified live against a Reel, a timeline video and a Story. --- app/Services/Social/FacebookAnalytics.php | 55 +++++++++++-------- .../Services/Social/FacebookAnalyticsTest.php | 44 +++++++++++---- 2 files changed, 67 insertions(+), 32 deletions(-) diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php index 925273614..f882a8380 100644 --- a/app/Services/Social/FacebookAnalytics.php +++ b/app/Services/Social/FacebookAnalytics.php @@ -50,7 +50,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - [$edge, $metrics] = $this->postMetricsFor($postPlatform->content_type); + [$edge, $metrics] = $this->postMetricsFor($postPlatform); $response = $this->socialHttp() ->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/{$edge}", [ @@ -78,45 +78,56 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } /** - * Each Facebook publish type stores a different kind of Graph node, and each - * node exposes its own insights: a feed post has `/insights` with `post_*` - * metrics, a Reel is a bare video whose numbers live on `/video_insights`, - * and a Story only answers to the `story` metric family. Asking a Story or - * a Reel for `post_impressions` is a `#100` rejection, not an empty result. + * The stored id tells us which Graph node we are looking at, and each node + * answers a different insights call: * - * The `post_impressions*` family is deprecated above Graph API v25, so feed - * posts read the `media_view` replacements instead. + * - 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. * * @return array{0: string, 1: array} */ - private function postMetricsFor(?ContentType $contentType): array + private function postMetricsFor(PostPlatform $postPlatform): array { - return match ($contentType) { - ContentType::FacebookReel => ['video_insights', [ - 'total_video_impressions' => 'analytics.metrics.impressions', - 'total_video_views' => 'analytics.metrics.video_views', - 'total_video_reactions_by_type_total' => 'analytics.metrics.reactions', - ]], - ContentType::FacebookStory => ['insights', [ + 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', - ]], - default => ['insights', [ + ]]; + } + + 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_type_total` family returns one - * count per reaction type and is reported as their sum. + * 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 { diff --git a/tests/Feature/Services/Social/FacebookAnalyticsTest.php b/tests/Feature/Services/Social/FacebookAnalyticsTest.php index 65cd9e0b5..98531f146 100644 --- a/tests/Feature/Services/Social/FacebookAnalyticsTest.php +++ b/tests/Feature/Services/Social/FacebookAnalyticsTest.php @@ -83,24 +83,48 @@ function facebookPostPlatform(ContentType $contentType, string $platformPostId): Http::assertNotSent(fn ($request) => str_contains($request['metric'] ?? '', 'post_impressions')); }); -test('facebook analytics reads reel metrics from the video insights edge', function () { +test('facebook analytics reads a bare video id from the video insights edge', function (ContentType $contentType) { Http::fake([ - "{$this->graph}/reel_video_123/video_insights*" => Http::response(facebookInsightsResponse([ - ['name' => 'total_video_impressions', 'value' => 500], - ['name' => 'total_video_views', 'value' => 210], - ['name' => 'total_video_reactions_by_type_total', 'value' => ['like' => 4, 'love' => 2, 'haha' => 1]], + "{$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::FacebookReel, 'reel_video_123')); + $metrics = (new FacebookAnalytics)->fetchPostMetrics(facebookPostPlatform($contentType, '2984721568539922')); expect($metrics)->toBe([ - ['label' => 'Impressions', 'value' => 500], - ['label' => 'Video Views', 'value' => 210], - ['label' => 'Reactions', 'value' => 7], + ['label' => 'Video Views', 'value' => 15], + ['label' => 'Reactions', 'value' => 6], + ['label' => 'Interactions', 'value' => 3], ]); - Http::assertNotSent(fn ($request) => str_starts_with($request->url(), "{$this->graph}/reel_video_123/insights")); + 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 () { From d02f0b3334a2b449fac1b3bff48b1c3a334eb25f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 11:33:22 -0300 Subject: [PATCH 15/15] Pin Facebook post insights to the lifetime period Without a period Meta answers some post metrics twice, once per period, so the card would have shown Reach and Likes two times. Every insights call now asks for lifetime values only; verified on a feed post, a video and a story node. --- app/Services/Social/FacebookAnalytics.php | 5 ++++- .../Services/Social/FacebookAnalyticsTest.php | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/Services/Social/FacebookAnalytics.php b/app/Services/Social/FacebookAnalytics.php index f882a8380..d3f25b64e 100644 --- a/app/Services/Social/FacebookAnalytics.php +++ b/app/Services/Social/FacebookAnalytics.php @@ -55,6 +55,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $response = $this->socialHttp() ->get("{$this->baseUrl}/{$postPlatform->platform_post_id}/{$edge}", [ 'metric' => implode(',', array_keys($metrics)), + 'period' => 'lifetime', 'access_token' => $account->access_token, ]); @@ -91,7 +92,9 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array * * 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. + * 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} */ diff --git a/tests/Feature/Services/Social/FacebookAnalyticsTest.php b/tests/Feature/Services/Social/FacebookAnalyticsTest.php index 98531f146..3b435df2d 100644 --- a/tests/Feature/Services/Social/FacebookAnalyticsTest.php +++ b/tests/Feature/Services/Social/FacebookAnalyticsTest.php @@ -72,9 +72,24 @@ function facebookPostPlatform(ContentType $contentType, string $platformPostId): ]); 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['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();