trypost/tests/Feature/Services/Social/FacebookPublisherTest.php
Paulo Castellano 6496588bbc
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).

Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.

The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.

The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.

Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.

Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 15:31:05 -03:00

944 lines
33 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\FacebookPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\FacebookPublisher;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
function facebookJpegBytes(int $width = 1200, int $height = 800): string
{
$manager = new ImageManager(Driver::class);
$image = $manager->createImage($width, $height)->fill('888888');
return (string) $image->encodeUsingMediaType('image/jpeg', quality: 80);
}
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->facebook()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'page_123',
'username' => 'myfbpage',
'token_expires_at' => null, // Facebook page tokens don't expire
'meta' => [
'page_id' => 'page_123',
'user_id' => 'fb_user_123',
],
]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Check out this Facebook post!',
]);
$this->postPlatform = PostPlatform::factory()->facebook()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::Facebook,
'content_type' => ContentType::FacebookPost,
]);
$this->publisher = new FacebookPublisher;
});
test('facebook publisher can publish text only post', function () {
Http::fake([
'*/page_123/feed' => Http::response([
'id' => 'page_123_post_456',
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result)->toHaveKey('url');
expect($result['id'])->toBe('page_123_post_456');
expect($result['url'])->toBe('https://www.facebook.com/page_123_post_456');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/feed')
&& $request['message'] === 'Check out this Facebook post!';
});
});
test('facebook publisher can publish single image post', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response([
'id' => 'photo_123',
'post_id' => 'page_123_photo_post_456',
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('page_123_photo_post_456');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& $request['message'] === 'Check out this Facebook post!';
});
});
test('facebook publisher can publish multi image post', 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(['id' => 'photo_2'], 200)
->push(['id' => 'photo_3'], 200),
'*/page_123/feed' => Http::response([
'id' => 'page_123_multi_post_789',
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('page_123_multi_post_789');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/feed')
&& str_contains($request->header('Content-Type')[0] ?? '', 'application/x-www-form-urlencoded')
&& ($request->data()['attached_media[0]'] ?? null) === json_encode(['media_fbid' => 'photo_1'])
&& ($request->data()['attached_media[1]'] ?? null) === json_encode(['media_fbid' => 'photo_2']);
});
});
test('facebook publisher sends graph api requests as form-urlencoded not json', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& str_contains($request->header('Content-Type')[0] ?? '', 'application/x-www-form-urlencoded')
&& ! str_contains($request->header('Content-Type')[0] ?? '', 'application/json');
});
});
test('facebook publisher can publish video post', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
'*/page_123/videos' => Http::response([
'id' => 'video_123',
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('video_123');
expect($result['url'])->toBe('https://www.facebook.com/page_123/videos/video_123');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/videos')
&& $request['description'] === 'Check out this Facebook post!';
});
});
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 return upload_url for reel start.'
);
});
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]);
$this->post->update([
'media' => [
[
'id' => 'test-media-story',
'path' => 'media/2026-01/story.jpg',
'url' => 'https://example.com/media/2026-01/story.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'story.jpg',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(FacebookPublishException::class, 'Facebook Stories require a video file.');
});
test('facebook publisher can publish video story', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookStory]);
$this->post->update([
'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',
],
],
]);
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),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('video_story_post_123');
});
test('facebook publisher throws exception on api error', function () {
Http::fake([
'*/page_123/feed' => Http::response([
'error' => [
'message' => 'Invalid request',
'type' => 'GraphMethodException',
'code' => 100,
],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class);
});
test('facebook publisher throws token expired exception on oauth error', function () {
Http::fake([
'*/page_123/feed' => Http::response([
'error' => [
'message' => 'Error validating access token',
'type' => 'OAuthException',
'code' => 190,
],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('facebook publisher throws token expired exception on session expired subcode', function () {
Http::fake([
'*/page_123/feed' => Http::response([
'error' => [
'message' => 'Session has expired',
'type' => 'OAuthException',
'code' => 190,
'error_subcode' => 463,
],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('facebook publisher throws exception for unsupported content type', function () {
$this->postPlatform->update(['content_type' => ContentType::InstagramFeed]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Unsupported Facebook content type');
});
test('facebook publisher throws exception when multi image upload fails', 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::response([
'error' => ['message' => 'Upload failed'],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Failed to upload any images to Facebook');
});
test('facebook publisher throws exception for unsupported media type', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-doc',
'path' => 'media/2026-01/doc.pdf',
'url' => 'https://example.com/media/2026-01/doc.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'doc.pdf',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Unsupported media type for Facebook');
});
test('facebook publisher throws exception for text post with null content', function () {
$this->post->update(['content' => null]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'Facebook text posts require content');
});
test('facebook publisher cleans up temp files after reel upload', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->post->update([
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
Http::fake([
'*/page_123/video_reels' => Http::sequence()
->push([
'video_id' => 'reel_video_cleanup_123',
'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_cleanup_123',
], 200)
->push(['id' => 'reel_cleanup_456', 'success' => true], 200),
'*example.com/media/*' => Http::response('fake-video', 200),
'*rupload.facebook.com/*' => Http::response(['success' => true], 200),
]);
$this->publisher->publish($this->postPlatform);
// Assert no leftover fb_reel_ temp files exist
$tempDir = sys_get_temp_dir();
$leftoverFiles = glob("{$tempDir}/fb_reel_*") ?: [];
expect($leftoverFiles)->toBeEmpty();
});
test('facebook publisher can publish single image with null content', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/test-image.jpg',
'url' => 'https://example.com/media/2026-01/test-image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'test.jpg',
],
],
]);
Http::fake([
'https://graph.facebook.com/v25.0/page_123/photos' => Http::response([
'id' => 'photo-123',
'post_id' => 'post-123',
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('post-123');
});
test('single image post without caption omits message from payload', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo-123', 'post_id' => 'post-123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& ! array_key_exists('message', $request->data());
});
});
test('multi-image post without caption omits message from payload', function () {
$mediaItems = [];
for ($i = 1; $i <= 2; $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(['content' => null, 'media' => $mediaItems]);
Http::fake([
'*/page_123/photos' => Http::sequence()
->push(['id' => 'photo_1'], 200)
->push(['id' => 'photo_2'], 200),
'*/page_123/feed' => Http::response(['id' => 'multi_post_789'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/feed')
&& ! array_key_exists('message', $request->data());
});
});
test('video post without description omits description from payload', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
'*/page_123/videos' => Http::response(['id' => 'video_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/videos')
&& ! array_key_exists('description', $request->data());
});
});
test('reel post without description omits description from payload (finish phase)', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->post->update([
'content' => null,
'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),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/page_123/video_reels')) {
return false;
}
$data = $request->data();
return data_get($data, 'upload_phase') === 'finish'
&& ! array_key_exists('description', $data);
});
});
test('facebook single image post applies the selected aspect ratio crop and uploads the crop', function (string $ratio, float $expected) {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => $ratio]]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response(facebookJpegBytes(1600, 900), 200),
'*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200),
]);
$this->publisher->publish($this->postPlatform);
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($crops)->toHaveCount(1);
$manager = new ImageManager(Driver::class);
$tempFile = tempnam(sys_get_temp_dir(), 'verify_');
file_put_contents($tempFile, Storage::get($crops->first()));
$image = $manager->decodePath($tempFile);
expect(round($image->width() / $image->height(), 2))->toBe(round($expected, 2));
@unlink($tempFile);
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& str_contains($request['url'], 'social-crops/')
&& ! str_contains($request['url'], 'example.com'));
})->with([
'1:1' => ['1:1', 1.0],
'4:5' => ['4:5', 4 / 5],
'16:9' => ['16:9', 16 / 9],
]);
test('facebook multi image post applies the chosen aspect ratio crop to every image', function (string $aspectRatio, float $expected) {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => $aspectRatio]]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://example.com/media/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response(facebookJpegBytes(1600, 900), 200),
'https://example.com/media/b.jpg' => Http::response(facebookJpegBytes(900, 1600), 200),
'*/page_123/photos' => Http::sequence()
->push(['id' => 'up_1'], 200)
->push(['id' => 'up_2'], 200),
'*/page_123/feed' => Http::response(['id' => 'post_1'], 200),
]);
$this->publisher->publish($this->postPlatform);
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($crops)->toHaveCount(2);
$manager = new ImageManager(Driver::class);
foreach ($crops as $cropPath) {
$tempFile = tempnam(sys_get_temp_dir(), 'verify_');
file_put_contents($tempFile, Storage::get($cropPath));
$image = $manager->decodePath($tempFile);
expect(abs($image->width() / $image->height() - $expected))->toBeLessThan(0.01);
@unlink($tempFile);
}
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& str_contains($request['url'] ?? '', 'social-crops/'));
})->with([
'1:1' => ['1:1', 1.0],
'4:5' => ['4:5', 4 / 5],
]);
test('facebook multi image post aborts entirely when one image cannot be downloaded for cropping', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
['id' => 'm2', 'path' => 'media/b.jpg', 'url' => 'https://example.com/media/b.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'b.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('', 404),
'https://example.com/media/b.jpg' => Http::response(facebookJpegBytes(900, 1600), 200),
'*/page_123/photos' => Http::response(['id' => 'up'], 200),
'*/page_123/feed' => Http::response(['id' => 'post_1'], 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(FacebookPublishException::class, 'Failed to download image for cropping');
});
test('facebook image post throws when the source image cannot be downloaded for cropping', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('', 404),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(FacebookPublishException::class, 'Failed to download image for cropping');
});
test('facebook image post throws a clean exception when the crop source is not decodable', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('<html>error</html>', 200, ['Content-Type' => 'text/html']),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(FacebookPublishException::class, 'Failed to process image for cropping');
});
test('facebook single image post without aspect ratio uploads the original image (no crop)', function () {
Storage::fake();
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200),
]);
$this->publisher->publish($this->postPlatform);
expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')))->toBeEmpty();
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& $request['url'] === 'https://example.com/media/a.jpg');
});
test('facebook single image post with original aspect ratio uploads the original image (no crop)', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => 'original']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200),
]);
$this->publisher->publish($this->postPlatform);
expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')))->toBeEmpty();
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& $request['url'] === 'https://example.com/media/a.jpg');
});
test('facebook publisher sends capped alt text on single image post', function () {
$longAlt = str_repeat('a', 1500);
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
'meta' => ['alt_text' => $longAlt],
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
$expectedAlt = mb_substr($longAlt, 0, Platform::Facebook->altTextMaxLength());
Http::assertSent(function ($request) use ($expectedAlt) {
return str_contains($request->url(), '/page_123/photos')
&& data_get($request->data(), 'alt_text_custom') === $expectedAlt
&& strlen($expectedAlt) === Platform::Facebook->altTextMaxLength();
});
});
test('facebook publisher sends capped alt text for each image in multi image post', function () {
$longAlt1 = str_repeat('b', 1200);
$longAlt2 = str_repeat('c', 1200);
$this->post->update([
'media' => [
[
'id' => 'm1',
'path' => 'media/2026-01/image1.jpg',
'url' => 'https://example.com/media/2026-01/image1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image1.jpg',
'meta' => ['alt_text' => $longAlt1],
],
[
'id' => 'm2',
'path' => 'media/2026-01/image2.jpg',
'url' => 'https://example.com/media/2026-01/image2.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image2.jpg',
'meta' => ['alt_text' => $longAlt2],
],
],
]);
Http::fake([
'*/page_123/photos' => Http::sequence()
->push(['id' => 'photo_1'], 200)
->push(['id' => 'photo_2'], 200),
'*/page_123/feed' => Http::response(['id' => 'multi_post_alt'], 200),
]);
$this->publisher->publish($this->postPlatform);
$expectedAlt1 = mb_substr($longAlt1, 0, Platform::Facebook->altTextMaxLength());
$expectedAlt2 = mb_substr($longAlt2, 0, Platform::Facebook->altTextMaxLength());
Http::assertSent(function ($request) use ($expectedAlt1) {
return str_contains($request->url(), '/page_123/photos')
&& data_get($request->data(), 'alt_text_custom') === $expectedAlt1;
});
Http::assertSent(function ($request) use ($expectedAlt2) {
return str_contains($request->url(), '/page_123/photos')
&& data_get($request->data(), 'alt_text_custom') === $expectedAlt2;
});
});
test('facebook publisher omits alt_text_custom from photos payload when no alt text is set', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_123', 'post_id' => 'post_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& ! array_key_exists('alt_text_custom', $request->data());
});
});
test('facebook publisher keeps links intact', function () {
config()->set('trypost.platforms.x.defuse_links', true);
$this->post->update(['content' => 'New post: https://acme.com/blog']);
Http::fake(['*/page_123/feed' => Http::response(['id' => 'page_123_post_456'], 200)]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/feed')
&& $request['message'] === 'New post: https://acme.com/blog');
});