diff --git a/app/Services/Social/Concerns/CropsImageForAspectRatio.php b/app/Services/Social/Concerns/CropsImageForAspectRatio.php index f006ee8b..f807e62a 100644 --- a/app/Services/Social/Concerns/CropsImageForAspectRatio.php +++ b/app/Services/Social/Concerns/CropsImageForAspectRatio.php @@ -12,12 +12,14 @@ trait CropsImageForAspectRatio { + private const CROP_DIRECTORY = 'social-crops'; + /** * Crop the image to the user-selected aspect ratio and return a public URL * the platform can fetch. Returns the original URL untouched when no ratio * is set or 'original' is selected. */ - protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio, string $pathPrefix): string + protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio): string { if (! $aspectRatio || $aspectRatio === 'original') { return $imageUrl; @@ -36,7 +38,7 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati $cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio); - $path = "{$pathPrefix}/".Str::uuid()->toString().'.jpg'; + $path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; Storage::put($path, file_get_contents($cropped)); @unlink($cropped); diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index 20080e93..9816fc9d 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -117,7 +117,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { $payload = [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'), + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'access_token' => $accessToken, ]; @@ -155,7 +155,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str } $uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [ - 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'), + 'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio), 'published' => 'false', 'access_token' => $accessToken, ]); diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 79540f01..23e684d8 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -79,7 +79,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $ private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array { - $imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops'); + $imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio); // Step 1: Create container $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [ @@ -205,7 +205,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri $params['video_url'] = $media->url; $params['media_type'] = 'VIDEO'; } else { - $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops'); + $params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio); } $containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params); diff --git a/tests/Feature/Services/Social/FacebookPublisherTest.php b/tests/Feature/Services/Social/FacebookPublisherTest.php index 4a8ce7b3..79b9c6ac 100644 --- a/tests/Feature/Services/Social/FacebookPublisherTest.php +++ b/tests/Feature/Services/Social/FacebookPublisherTest.php @@ -665,7 +665,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($crops)->toHaveCount(1); $manager = new ImageManager(Driver::class); @@ -676,7 +676,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string @unlink($tempFile); Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') - && str_contains($request['url'], 'facebook-crops/') + && str_contains($request['url'], 'social-crops/') && ! str_contains($request['url'], 'example.com')); })->with([ '1:1' => ['1:1', 1.0], @@ -706,7 +706,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')); + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($crops)->toHaveCount(2); $manager = new ImageManager(Driver::class); @@ -719,7 +719,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string } Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos') - && str_contains($request['url'] ?? '', 'facebook-crops/')); + && str_contains($request['url'] ?? '', 'social-crops/')); }); test('facebook image post throws when the source image cannot be downloaded for cropping', function () { @@ -755,7 +755,29 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')))->toBeEmpty(); + 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'); diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index fd45ee73..c32db466 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; @@ -726,10 +727,10 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string ->toThrow(Exception::class); }); -test('feed image is cropped to chosen aspect ratio before publishing', function () { +test('feed image is cropped to chosen aspect ratio before publishing', function (string $aspectRatio, float $expected) { Storage::fake(); - $this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]); + $this->postPlatform->update(['meta' => ['aspect_ratio' => $aspectRatio]]); $this->post->update([ 'media' => [ @@ -753,15 +754,14 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'instagram-crops/')); + $cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($cropped)->not->toBeNull(); $manager = new ImageManager(Driver::class); $tempFile = tempnam(sys_get_temp_dir(), 'verify_'); file_put_contents($tempFile, Storage::get($cropped)); $image = $manager->decodePath($tempFile); - $ratio = $image->width() / $image->height(); - expect(abs($ratio - 0.8))->toBeLessThan(0.01); + expect(abs($image->width() / $image->height() - $expected))->toBeLessThan(0.01); @unlink($tempFile); Http::assertSent(function ($request) { @@ -770,9 +770,31 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string } $imageUrl = $request['image_url'] ?? ''; - return str_contains($imageUrl, 'instagram-crops/') + return str_contains($imageUrl, 'social-crops/') && ! str_contains($imageUrl, 'example.com/media/test.jpg'); }); +})->with([ + '1:1' => ['1:1', 1.0], + '4:5' => ['4:5', 4 / 5], + '16:9' => ['16:9', 16 / 9], +]); + +test('feed image 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(InstagramPublishException::class, 'Failed to download image for cropping'); }); test('feed image with original aspect ratio bypasses crop', function () { @@ -875,7 +897,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string $this->publisher->publish($this->postPlatform); - $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'instagram-crops/')); + $crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')); expect($crops)->toHaveCount(2); $manager = new ImageManager(Driver::class);